From 1f11b5e641d99aaf94fea6e522af291e5e587c8b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 11 Oct 2025 18:28:21 -0400 Subject: [PATCH] Refactor mounting logic from managers to server (#2069) --- .cursor/worktrees.json | 6 + docs/servers/composition.mdx | 58 +++ .../component_manager/component_service.py | 12 +- src/fastmcp/prompts/prompt_manager.py | 121 +---- src/fastmcp/resources/resource_manager.py | 175 +------ src/fastmcp/server/server.py | 435 +++++++++++++++--- src/fastmcp/tools/tool_manager.py | 126 +---- src/fastmcp/utilities/inspect.py | 37 +- tests/client/test_client.py | 7 +- .../server/openapi/test_advanced_behavior.py | 9 +- tests/server/openapi/test_configuration.py | 29 +- .../openapi/test_description_propagation.py | 3 +- tests/server/test_mount.py | 122 ++++- tests/server/test_server.py | 9 +- tests/tools/test_tool_manager.py | 24 +- tests/utilities/test_inspect.py | 192 ++++++++ 16 files changed, 840 insertions(+), 525 deletions(-) create mode 100644 .cursor/worktrees.json diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 000000000..3321da678 --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,6 @@ +{ + "setup-worktree": [ + "uv sync", + "uv run pre-commit install" + ] +} diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index 63ac1f773..4ca1895ce 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -263,6 +263,64 @@ main_server.mount(remote_proxy, prefix="remote") +## Tag Filtering with Composition + + + +When using `include_tags` or `exclude_tags` on a parent server, these filters apply **recursively** to all components, including those from mounted or imported servers. This allows you to control which components are exposed at the parent level, regardless of how your application is composed. + +```python +import asyncio +from fastmcp import FastMCP, Client + +# Create a subserver with tools tagged for different environments +api_server = FastMCP(name="APIServer") + +@api_server.tool(tags={"production"}) +def prod_endpoint() -> str: + """Production-ready endpoint.""" + return "Production data" + +@api_server.tool(tags={"development"}) +def dev_endpoint() -> str: + """Development-only endpoint.""" + return "Debug data" + +# Mount the subserver with production tag filtering at parent level +prod_app = FastMCP(name="ProductionApp", include_tags={"production"}) +prod_app.mount(api_server, prefix="api") + +# Test the filtering +async def test_filtering(): + async with Client(prod_app) as client: + tools = await client.list_tools() + print("Available tools:", [t.name for t in tools]) + # Shows: ['api_prod_endpoint'] + # The 'api_dev_endpoint' is filtered out + + # Calling the filtered tool raises an error + try: + await client.call_tool("api_dev_endpoint") + except Exception as e: + print(f"Filtered tool not accessible: {e}") + +if __name__ == "__main__": + asyncio.run(test_filtering()) +``` + +### How Recursive Filtering Works + +Tag filters apply in the following order: + +1. **Child Server Filters**: Each mounted/imported server first applies its own `include_tags`/`exclude_tags` to its components. +2. **Parent Server Filters**: The parent server then applies its own `include_tags`/`exclude_tags` to all components, including those from child servers. + +This ensures that parent server tag policies act as a global policy for everything the parent server exposes, no matter how your application is composed. + + +This filtering applies to both **listing** (e.g., `list_tools()`) and **execution** (e.g., `call_tool()`). Filtered components are neither visible nor executable through the parent server. + + ## Resource Prefix Formats diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index b63dff339..b23f96420 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -41,7 +41,7 @@ class ComponentService: return tool # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._tool_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if key.startswith(f"{mounted.prefix}_"): tool_key = key.removeprefix(f"{mounted.prefix}_") @@ -70,7 +70,7 @@ class ComponentService: return tool # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._tool_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if key.startswith(f"{mounted.prefix}_"): tool_key = key.removeprefix(f"{mounted.prefix}_") @@ -103,7 +103,7 @@ class ComponentService: return template # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._resource_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if has_resource_prefix( key, @@ -146,7 +146,7 @@ class ComponentService: return template # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._resource_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if has_resource_prefix( key, @@ -185,7 +185,7 @@ class ComponentService: return prompt # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._prompt_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if key.startswith(f"{mounted.prefix}_"): prompt_key = key.removeprefix(f"{mounted.prefix}_") @@ -213,7 +213,7 @@ class ComponentService: return prompt # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._prompt_manager._mounted_servers): + for mounted in reversed(self._server._mounted_servers): if mounted.prefix: if key.startswith(f"{mounted.prefix}_"): prompt_key = key.removeprefix(f"{mounted.prefix}_") diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index 56da9289c..1563a1831 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations as _annotations import warnings from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any +from typing import Any from mcp import GetPromptResult @@ -12,9 +12,6 @@ from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult 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__) @@ -27,7 +24,6 @@ class PromptManager: mask_error_details: bool | None = None, ): self._prompts: dict[str, Prompt] = {} - self._mounted_servers: list[MountedServer] = [] self.mask_error_details = mask_error_details or settings.mask_error_details # Default to "warn" if None is provided @@ -42,54 +38,6 @@ class PromptManager: self.duplicate_behavior = duplicate_behavior - def mount(self, server: MountedServer) -> None: - """Adds a mounted server as a source for prompts.""" - self._mounted_servers.append(server) - - async def _load_prompts( - self, *, apply_filtering: bool = False - ) -> dict[str, Prompt]: - """ - The single, consolidated recursive method for fetching prompts. The 'apply_filtering' - parameter determines the communication path. - - - apply_filtering=False: Manager-to-manager path for complete, unfiltered inventory - - apply_filtering=True: Server-to-server path for filtered MCP requests - """ - all_prompts: dict[str, Prompt] = {} - - for mounted in self._mounted_servers: - try: - if apply_filtering: - # Use the server-to-server filtered path - child_results = await mounted.server._list_prompts_middleware() - else: - # Use the manager-to-manager unfiltered path - 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 mounted.prefix: - for prompt in child_dict.values(): - prefixed_prompt = prompt.model_copy( - key=f"{mounted.prefix}_{prompt.key}" - ) - all_prompts[prefixed_prompt.key] = prefixed_prompt - else: - all_prompts.update(child_dict) - except Exception as e: - # Skip failed mounts silently, matches existing behavior - logger.warning( - f"Failed to get prompts from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}" - ) - if settings.mounted_components_raise_on_load_error: - raise - continue - - # Finally, add local prompts, which always take precedence - all_prompts.update(self._prompts) - return all_prompts - async def has_prompt(self, key: str) -> bool: """Check if a prompt exists.""" prompts = await self.get_prompts() @@ -104,16 +52,9 @@ class PromptManager: async def get_prompts(self) -> dict[str, Prompt]: """ - Gets the complete, unfiltered inventory of all prompts. + Gets the complete, unfiltered inventory of local prompts. """ - return await self._load_prompts(apply_filtering=False) - - async def list_prompts(self) -> list[Prompt]: - """ - Lists all prompts, applying protocol filtering. - """ - prompts_dict = await self._load_prompts(apply_filtering=True) - return list(prompts_dict.values()) + return dict(self._prompts) def add_prompt_from_fn( self, @@ -162,46 +103,16 @@ class PromptManager: Internal API for servers: Finds and renders a prompt, respecting the filtered protocol path. """ - # 1. Check local prompts first. The server will have already applied its filter. - if name in self._prompts: - prompt = await self.get_prompt(name) - if not prompt: - raise NotFoundError(f"Unknown prompt: {name}") - - try: - messages = await prompt.render(arguments) - return GetPromptResult( - description=prompt.description, messages=messages - ) - - # Pass through PromptErrors as-is - except PromptError as e: - logger.exception(f"Error rendering prompt {name!r}") - raise e - - # Handle other exceptions - except Exception as e: - logger.exception(f"Error rendering prompt {name!r}") - if self.mask_error_details: - # Mask internal details - raise PromptError(f"Error rendering prompt {name!r}") from e - else: - # Include original error details - raise PromptError(f"Error rendering prompt {name!r}: {e}") from e - - # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._mounted_servers): - prompt_key = name - if mounted.prefix: - if name.startswith(f"{mounted.prefix}_"): - prompt_key = name.removeprefix(f"{mounted.prefix}_") - else: - continue - try: - return await mounted.server._get_prompt_middleware( - prompt_key, arguments - ) - except NotFoundError: - continue - - raise NotFoundError(f"Unknown prompt: {name}") + prompt = await self.get_prompt(name) + try: + messages = await prompt.render(arguments) + return GetPromptResult(description=prompt.description, messages=messages) + except PromptError as e: + logger.exception(f"Error rendering prompt {name!r}") + raise e + except Exception as e: + logger.exception(f"Error rendering prompt {name!r}") + if self.mask_error_details: + raise PromptError(f"Error rendering prompt {name!r}") from e + else: + raise PromptError(f"Error rendering prompt {name!r}: {e}") from e diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index d66a49ccf..07331ae18 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -5,7 +5,7 @@ from __future__ import annotations import inspect import warnings from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import Any from pydantic import AnyUrl @@ -19,9 +19,6 @@ 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__) @@ -43,7 +40,6 @@ class ResourceManager: """ self._resources: dict[str, Resource] = {} self._templates: dict[str, ResourceTemplate] = {} - self._mounted_servers: list[MountedServer] = [] self.mask_error_details = mask_error_details or settings.mask_error_details # Default to "warn" if None is provided @@ -57,143 +53,13 @@ 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_servers.append(server) - async def get_resources(self) -> dict[str, Resource]: """Get all registered resources, keyed by URI.""" - return await self._load_resources(apply_filtering=False) + return dict(self._resources) async def get_resource_templates(self) -> dict[str, ResourceTemplate]: """Get all registered templates, keyed by URI template.""" - return await self._load_resource_templates(apply_filtering=False) - - async def _load_resources( - self, *, apply_filtering: bool = False - ) -> dict[str, Resource]: - """ - The single, consolidated recursive method for fetching resources. The 'apply_filtering' - parameter determines the communication path. - - - apply_filtering=False: Manager-to-manager path for complete, unfiltered inventory - - apply_filtering=True: Server-to-server path for filtered MCP requests - """ - all_resources: dict[str, Resource] = {} - - for mounted in self._mounted_servers: - try: - if apply_filtering: - # Use the server-to-server filtered path - child_resources_list = ( - await mounted.server._list_resources_middleware() - ) - child_resources = { - resource.key: resource for resource in child_resources_list - } - else: - # 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 and name - prefixed_resource = resource.model_copy( - update={"name": f"{mounted.prefix}_{resource.name}"}, - 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 server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}" - ) - if settings.mounted_components_raise_on_load_error: - raise - continue - - # Finally, add local resources, which always take precedence - all_resources.update(self._resources) - return all_resources - - async def _load_resource_templates( - self, *, apply_filtering: bool = False - ) -> dict[str, ResourceTemplate]: - """ - The single, consolidated recursive method for fetching templates. The 'apply_filtering' - parameter determines the communication path. - - - apply_filtering=False: Manager-to-manager path for complete, unfiltered inventory - - apply_filtering=True: Server-to-server path for filtered MCP requests - """ - all_templates: dict[str, ResourceTemplate] = {} - - for mounted in self._mounted_servers: - try: - if apply_filtering: - # Use the server-to-server filtered path - child_templates = ( - await mounted.server._list_resource_templates_middleware() - ) - else: - # 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 and name - prefixed_template = template.model_copy( - update={"name": f"{mounted.prefix}_{template.name}"}, - 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 server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}" - ) - if settings.mounted_components_raise_on_load_error: - raise - 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(apply_filtering=True) - 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(apply_filtering=True) - return list(templates_dict.values()) + return dict(self._templates) def add_resource_or_template_from_fn( self, @@ -387,12 +253,12 @@ class ResourceManager: uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) - # First check concrete resources (local and mounted) + # First check concrete resources resources = await self.get_resources() if resource := resources.get(uri_str): return resource - # Then check templates (local and mounted) - use the utility function to match against storage keys + # Then check templates 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) @@ -430,9 +296,6 @@ class ResourceManager: # 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") - try: return await resource.read() @@ -477,32 +340,4 @@ class ResourceManager: 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_servers): - key = uri_str - try: - if mounted.prefix: - if has_resource_prefix( - key, - mounted.prefix, - mounted.resource_prefix_format, - ): - key = remove_resource_prefix( - key, - mounted.prefix, - mounted.resource_prefix_format, - ) - else: - continue - - try: - result = await mounted.server._read_resource_middleware(key) - return result[0].content - except NotFoundError: - continue - 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 67cdbd9c0..81290e5cd 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -410,8 +410,24 @@ class FastMCP(Generic[LifespanResultT]): self.middleware.append(middleware) async def get_tools(self) -> dict[str, Tool]: - """Get all registered tools, indexed by registered key.""" - return await self._tool_manager.get_tools() + """Get all tools (unfiltered), including mounted servers, indexed by key.""" + all_tools = dict(await self._tool_manager.get_tools()) + + for mounted in self._mounted_servers: + try: + child_tools = await mounted.server.get_tools() + for key, tool in child_tools.items(): + new_key = f"{mounted.prefix}_{key}" if mounted.prefix else key + all_tools[new_key] = tool.model_copy(key=new_key) + except Exception as e: + logger.warning( + f"Failed to get tools from mounted server {mounted.server.name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return all_tools async def get_tool(self, key: str) -> Tool: tools = await self.get_tools() @@ -420,8 +436,37 @@ class FastMCP(Generic[LifespanResultT]): return tools[key] async def get_resources(self) -> dict[str, Resource]: - """Get all registered resources, indexed by registered key.""" - return await self._resource_manager.get_resources() + """Get all resources (unfiltered), including mounted servers, indexed by key.""" + all_resources = dict(await self._resource_manager.get_resources()) + + for mounted in self._mounted_servers: + try: + child_resources = await mounted.server.get_resources() + for key, resource in child_resources.items(): + new_key = ( + add_resource_prefix( + key, mounted.prefix, mounted.resource_prefix_format + ) + if mounted.prefix + else key + ) + update = ( + {"name": f"{mounted.prefix}_{resource.name}"} + if mounted.prefix and resource.name + else {} + ) + all_resources[new_key] = resource.model_copy( + key=new_key, update=update + ) + except Exception as e: + logger.warning( + f"Failed to get resources from mounted server {mounted.server.name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return all_resources async def get_resource(self, key: str) -> Resource: resources = await self.get_resources() @@ -430,8 +475,37 @@ class FastMCP(Generic[LifespanResultT]): return resources[key] async def get_resource_templates(self) -> dict[str, ResourceTemplate]: - """Get all registered resource templates, indexed by registered key.""" - return await self._resource_manager.get_resource_templates() + """Get all resource templates (unfiltered), including mounted servers, indexed by key.""" + all_templates = dict(await self._resource_manager.get_resource_templates()) + + for mounted in self._mounted_servers: + try: + child_templates = await mounted.server.get_resource_templates() + for key, template in child_templates.items(): + new_key = ( + add_resource_prefix( + key, mounted.prefix, mounted.resource_prefix_format + ) + if mounted.prefix + else key + ) + update = ( + {"name": f"{mounted.prefix}_{template.name}"} + if mounted.prefix and template.name + else {} + ) + all_templates[new_key] = template.model_copy( + key=new_key, update=update + ) + except Exception as e: + logger.warning( + f"Failed to get resource templates from mounted server {mounted.server.name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return all_templates async def get_resource_template(self, key: str) -> ResourceTemplate: """Get a registered resource template by key.""" @@ -441,10 +515,24 @@ class FastMCP(Generic[LifespanResultT]): return templates[key] async def get_prompts(self) -> dict[str, Prompt]: - """ - List all available prompts. - """ - return await self._prompt_manager.get_prompts() + """Get all prompts (unfiltered), including mounted servers, indexed by key.""" + all_prompts = dict(await self._prompt_manager.get_prompts()) + + for mounted in self._mounted_servers: + try: + child_prompts = await mounted.server.get_prompts() + for key, prompt in child_prompts.items(): + new_key = f"{mounted.prefix}_{key}" if mounted.prefix else key + all_prompts[new_key] = prompt.model_copy(key=new_key) + except Exception as e: + logger.warning( + f"Failed to get prompts from mounted server {mounted.server.name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return all_prompts async def get_prompt(self, key: str) -> Prompt: prompts = await self.get_prompts() @@ -560,16 +648,45 @@ class FastMCP(Generic[LifespanResultT]): context: MiddlewareContext[mcp.types.ListToolsRequest], ) -> list[Tool]: """ - List all available tools + List all available tools. """ - tools = await self._tool_manager.list_tools() # type: ignore[reportPrivateUsage] + # 1. Get local tools and filter them + local_tools = await self._tool_manager.get_tools() + filtered_local = [ + tool for tool in local_tools.values() if self._should_enable_component(tool) + ] - mcp_tools: list[Tool] = [] - for tool in tools: - if self._should_enable_component(tool): - mcp_tools.append(tool) + # 2. Get tools from mounted servers + # Mounted servers apply their own filtering, but we also apply parent's filtering + # Use a dict to implement "later wins" deduplication by key + all_tools: dict[str, Tool] = {tool.key: tool for tool in filtered_local} - return mcp_tools + for mounted in self._mounted_servers: + try: + child_tools = await mounted.server._list_tools_middleware() + for tool in child_tools: + # Apply parent server's filtering to mounted components + if not self._should_enable_component(tool): + continue + + key = tool.key + if mounted.prefix: + key = f"{mounted.prefix}_{tool.key}" + tool = tool.model_copy(key=key) + # Later mounted servers override earlier ones + all_tools[key] = tool + except Exception as e: + server_name = getattr( + getattr(mounted, "server", None), "name", repr(mounted) + ) + logger.warning( + f"Failed to list tools from mounted server {server_name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return list(all_tools.values()) async def _list_resources_mcp(self) -> list[MCPResource]: """ @@ -611,16 +728,54 @@ class FastMCP(Generic[LifespanResultT]): context: MiddlewareContext[dict[str, Any]], ) -> list[Resource]: """ - List all available resources + List all available resources. """ - resources = await self._resource_manager.list_resources() # type: ignore[reportPrivateUsage] + # 1. Filter local resources + local_resources = await self._resource_manager.get_resources() + filtered_local = [ + resource + for resource in local_resources.values() + if self._should_enable_component(resource) + ] - mcp_resources: list[Resource] = [] - for resource in resources: - if self._should_enable_component(resource): - mcp_resources.append(resource) + # 2. Get from mounted servers with resource prefix handling + # Mounted servers apply their own filtering, but we also apply parent's filtering + # Use a dict to implement "later wins" deduplication by key + all_resources: dict[str, Resource] = { + resource.key: resource for resource in filtered_local + } - return mcp_resources + for mounted in self._mounted_servers: + try: + child_resources = await mounted.server._list_resources_middleware() + for resource in child_resources: + # Apply parent server's filtering to mounted components + if not self._should_enable_component(resource): + continue + + key = resource.key + if mounted.prefix: + key = add_resource_prefix( + resource.key, + mounted.prefix, + mounted.resource_prefix_format, + ) + resource = resource.model_copy( + key=key, + update={"name": f"{mounted.prefix}_{resource.name}"}, + ) + # Later mounted servers override earlier ones + all_resources[key] = resource + except Exception as e: + server_name = getattr( + getattr(mounted, "server", None), "name", repr(mounted) + ) + logger.warning(f"Failed to list resources from {server_name!r}: {e}") + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return list(all_resources.values()) async def _list_resource_templates_mcp(self) -> list[MCPResourceTemplate]: """ @@ -665,16 +820,58 @@ class FastMCP(Generic[LifespanResultT]): context: MiddlewareContext[dict[str, Any]], ) -> list[ResourceTemplate]: """ - List all available resource templates + List all available resource templates. """ - templates = await self._resource_manager.list_resource_templates() # type: ignore[reportPrivateUsage] + # 1. Filter local templates + local_templates = await self._resource_manager.get_resource_templates() + filtered_local = [ + template + for template in local_templates.values() + if self._should_enable_component(template) + ] - mcp_templates: list[ResourceTemplate] = [] - for template in templates: - if self._should_enable_component(template): - mcp_templates.append(template) + # 2. Get from mounted servers with resource prefix handling + # Mounted servers apply their own filtering, but we also apply parent's filtering + # Use a dict to implement "later wins" deduplication by key + all_templates: dict[str, ResourceTemplate] = { + template.key: template for template in filtered_local + } - return mcp_templates + for mounted in self._mounted_servers: + try: + child_templates = ( + await mounted.server._list_resource_templates_middleware() + ) + for template in child_templates: + # Apply parent server's filtering to mounted components + if not self._should_enable_component(template): + continue + + key = template.key + if mounted.prefix: + key = add_resource_prefix( + template.key, + mounted.prefix, + mounted.resource_prefix_format, + ) + template = template.model_copy( + key=key, + update={"name": f"{mounted.prefix}_{template.name}"}, + ) + # Later mounted servers override earlier ones + all_templates[key] = template + except Exception as e: + server_name = getattr( + getattr(mounted, "server", None), "name", repr(mounted) + ) + logger.warning( + f"Failed to list resource templates from {server_name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return list(all_templates.values()) async def _list_prompts_mcp(self) -> list[MCPPrompt]: """ @@ -717,16 +914,49 @@ class FastMCP(Generic[LifespanResultT]): context: MiddlewareContext[mcp.types.ListPromptsRequest], ) -> list[Prompt]: """ - List all available prompts + List all available prompts. """ - prompts = await self._prompt_manager.list_prompts() # type: ignore[reportPrivateUsage] + # 1. Filter local prompts + local_prompts = await self._prompt_manager.get_prompts() + filtered_local = [ + prompt + for prompt in local_prompts.values() + if self._should_enable_component(prompt) + ] - mcp_prompts: list[Prompt] = [] - for prompt in prompts: - if self._should_enable_component(prompt): - mcp_prompts.append(prompt) + # 2. Get from mounted servers + # Mounted servers apply their own filtering, but we also apply parent's filtering + # Use a dict to implement "later wins" deduplication by key + all_prompts: dict[str, Prompt] = { + prompt.key: prompt for prompt in filtered_local + } - return mcp_prompts + for mounted in self._mounted_servers: + try: + child_prompts = await mounted.server._list_prompts_middleware() + for prompt in child_prompts: + # Apply parent server's filtering to mounted components + if not self._should_enable_component(prompt): + continue + + key = prompt.key + if mounted.prefix: + key = f"{mounted.prefix}_{prompt.key}" + prompt = prompt.model_copy(key=key) + # Later mounted servers override earlier ones + all_prompts[key] = prompt + except Exception as e: + server_name = getattr( + getattr(mounted, "server", None), "name", repr(mounted) + ) + logger.warning( + f"Failed to list prompts from mounted server {server_name!r}: {e}" + ) + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + continue + + return list(all_prompts.values()) async def _call_tool_mcp( self, key: str, arguments: dict[str, Any] @@ -781,13 +1011,40 @@ class FastMCP(Generic[LifespanResultT]): """ Call a tool """ - tool = await self._tool_manager.get_tool(context.message.name) - if not self._should_enable_component(tool): - raise NotFoundError(f"Unknown tool: {context.message.name!r}") + tool_name = context.message.name - return await self._tool_manager.call_tool( - key=context.message.name, arguments=context.message.arguments or {} - ) + # Try mounted servers in reverse order (later wins) + for mounted in reversed(self._mounted_servers): + try_name = tool_name + if mounted.prefix: + if not tool_name.startswith(f"{mounted.prefix}_"): + continue + try_name = tool_name[len(mounted.prefix) + 1 :] + + try: + # First, get the tool to check if parent's filter allows it + tool = await mounted.server._tool_manager.get_tool(try_name) + if not self._should_enable_component(tool): + # Parent filter blocks this tool, continue searching + continue + + return await mounted.server._call_tool_middleware( + try_name, context.message.arguments or {} + ) + except NotFoundError: + continue + + # Try local tools last (mounted servers override local) + try: + tool = await self._tool_manager.get_tool(tool_name) + if self._should_enable_component(tool): + return await self._tool_manager.call_tool( + key=tool_name, arguments=context.message.arguments or {} + ) + except NotFoundError: + pass + + raise NotFoundError(f"Unknown tool: {tool_name!r}") async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ReadResourceContents]: """ @@ -837,17 +1094,46 @@ class FastMCP(Generic[LifespanResultT]): """ Read a resource """ - 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}") + uri_str = str(context.message.uri) - content = await self._resource_manager.read_resource(context.message.uri) - return [ - ReadResourceContents( - content=content, - mime_type=resource.mime_type, - ) - ] + # Try mounted servers in reverse order (later wins) + for mounted in reversed(self._mounted_servers): + key = uri_str + if mounted.prefix: + if not has_resource_prefix( + key, mounted.prefix, mounted.resource_prefix_format + ): + continue + key = remove_resource_prefix( + key, mounted.prefix, mounted.resource_prefix_format + ) + + try: + # First, get the resource to check if parent's filter allows it + resource = await mounted.server._resource_manager.get_resource(key) + if not self._should_enable_component(resource): + # Parent filter blocks this resource, continue searching + continue + result = await mounted.server._read_resource_middleware(key) + return result + except NotFoundError: + continue + + # Try local resources last (mounted servers override local) + try: + resource = await self._resource_manager.get_resource(uri_str) + if self._should_enable_component(resource): + content = await self._resource_manager.read_resource(uri_str) + return [ + ReadResourceContents( + content=content, + mime_type=resource.mime_type, + ) + ] + except NotFoundError: + pass + + raise NotFoundError(f"Unknown resource: {uri_str!r}") async def _get_prompt_mcp( self, name: str, arguments: dict[str, Any] | None = None @@ -893,13 +1179,39 @@ class FastMCP(Generic[LifespanResultT]): self, context: MiddlewareContext[mcp.types.GetPromptRequestParams], ) -> GetPromptResult: - prompt = await self._prompt_manager.get_prompt(context.message.name) - if not self._should_enable_component(prompt): - raise NotFoundError(f"Unknown prompt: {context.message.name!r}") + name = context.message.name - return await self._prompt_manager.render_prompt( - name=context.message.name, arguments=context.message.arguments - ) + # Try mounted servers in reverse order (later wins) + for mounted in reversed(self._mounted_servers): + try_name = name + if mounted.prefix: + if not name.startswith(f"{mounted.prefix}_"): + continue + try_name = name[len(mounted.prefix) + 1 :] + + try: + # First, get the prompt to check if parent's filter allows it + prompt = await mounted.server._prompt_manager.get_prompt(try_name) + if not self._should_enable_component(prompt): + # Parent filter blocks this prompt, continue searching + continue + return await mounted.server._get_prompt_middleware( + try_name, context.message.arguments + ) + except NotFoundError: + continue + + # Try local prompts last (mounted servers override local) + try: + prompt = await self._prompt_manager.get_prompt(name) + if self._should_enable_component(prompt): + return await self._prompt_manager.render_prompt( + name=name, arguments=context.message.arguments + ) + except NotFoundError: + pass + + raise NotFoundError(f"Unknown prompt: {name!r}") def add_tool(self, tool: Tool) -> Tool: """Add a tool to the server. @@ -1904,9 +2216,6 @@ class FastMCP(Generic[LifespanResultT]): resource_prefix_format=self.resource_prefix_format, ) self._mounted_servers.append(mounted_server) - self._tool_manager.mount(mounted_server) - self._resource_manager.mount(mounted_server) - self._prompt_manager.mount(mounted_server) async def import_server( self, diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index 02797f55f..cd74dc18e 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations import warnings from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import Any from mcp.types import ToolAnnotations @@ -16,9 +16,6 @@ from fastmcp.tools.tool_transform import ( ) from fastmcp.utilities.logging import get_logger -if TYPE_CHECKING: - from fastmcp.server.server import MountedServer - logger = get_logger(__name__) @@ -32,7 +29,6 @@ class ToolManager: transformations: dict[str, ToolTransformConfig] | None = None, ): self._tools: dict[str, Tool] = {} - self._mounted_servers: list[MountedServer] = [] self.mask_error_details = mask_error_details or settings.mask_error_details self.transformations = transformations or {} @@ -48,56 +44,12 @@ class ToolManager: self.duplicate_behavior = duplicate_behavior - def mount(self, server: MountedServer) -> None: - """Adds a mounted server as a source for tools.""" - self._mounted_servers.append(server) - - async def _load_tools(self, *, apply_filtering: bool = False) -> dict[str, Tool]: - """ - The single, consolidated recursive method for fetching tools. The 'apply_filtering' - parameter determines the communication path. - - - apply_filtering=False: Manager-to-manager path for complete, unfiltered inventory - - apply_filtering=True: Server-to-server path for filtered MCP requests - """ - all_tools: dict[str, Tool] = {} - - for mounted in self._mounted_servers: - try: - if apply_filtering: - # Use the server-to-server filtered path - child_results = await mounted.server._list_tools_middleware() - else: - # Use the manager-to-manager unfiltered path - 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 mounted.prefix: - for tool in child_dict.values(): - prefixed_tool = tool.model_copy( - key=f"{mounted.prefix}_{tool.key}" - ) - all_tools[prefixed_tool.key] = prefixed_tool - else: - all_tools.update(child_dict) - except Exception as e: - # Skip failed mounts silently, matches existing behavior - logger.warning( - f"Failed to get tools from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}" - ) - if settings.mounted_components_raise_on_load_error: - raise - continue - - # Finally, add local tools, which always take precedence - all_tools.update(self._tools) - + async def _load_tools(self) -> dict[str, Tool]: + """Return this manager's local tools with transformations applied.""" transformed_tools = apply_transformations_to_tools( - tools=all_tools, + tools=self._tools, transformations=self.transformations, ) - return transformed_tools async def has_tool(self, key: str) -> bool: @@ -114,25 +66,9 @@ class ToolManager: async def get_tools(self) -> dict[str, Tool]: """ - Gets the complete, unfiltered inventory of all tools. + Gets the complete, unfiltered inventory of local tools. """ - return await self._load_tools(apply_filtering=False) - - async def list_tools(self) -> list[Tool]: - """ - Lists all tools, applying protocol filtering. - """ - tools_dict = await self._load_tools(apply_filtering=True) - return list(tools_dict.values()) - - @property - def _tools_transformed(self) -> list[str]: - """Get the local tools.""" - - return [ - transformation.name or tool_name - for tool_name, transformation in self.transformations.items() - ] + return await self._load_tools() def add_tool_from_fn( self, @@ -214,41 +150,15 @@ class ToolManager: Internal API for servers: Finds and calls a tool, respecting the filtered protocol path. """ - # 1. Check local tools first. The server will have already applied its filter. - if key in self._tools or key in self._tools_transformed: - tool = await self.get_tool(key) - if not tool: - raise NotFoundError(f"Tool {key!r} not found") - - try: - return await tool.run(arguments) - - # raise ToolErrors as-is - except ToolError as e: - logger.exception(f"Error calling tool {key!r}") - raise e - - # Handle other exceptions - except Exception as e: - logger.exception(f"Error calling tool {key!r}") - if self.mask_error_details: - # Mask internal details - raise ToolError(f"Error calling tool {key!r}") from e - else: - # Include original error details - raise ToolError(f"Error calling tool {key!r}: {e}") from e - - # 2. Check mounted servers using the filtered protocol path. - for mounted in reversed(self._mounted_servers): - tool_key = key - if mounted.prefix: - if key.startswith(f"{mounted.prefix}_"): - tool_key = key.removeprefix(f"{mounted.prefix}_") - else: - continue - try: - return await mounted.server._call_tool_middleware(tool_key, arguments) - except NotFoundError: - continue - - raise NotFoundError(f"Tool {key!r} not found.") + tool = await self.get_tool(key) + try: + return await tool.run(arguments) + except ToolError as e: + logger.exception(f"Error calling tool {key!r}") + raise e + except Exception as e: + logger.exception(f"Error calling tool {key!r}") + if self.mask_error_details: + raise ToolError(f"Error calling tool {key!r}") from e + else: + raise ToolError(f"Error calling tool {key!r}: {e}") from e diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index f3303a8f4..f68921674 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -104,21 +104,20 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: Returns: FastMCPInfo dataclass containing the extracted information """ - # Get all the components using FastMCP2's direct methods - tools_dict = await mcp.get_tools() - prompts_dict = await mcp.get_prompts() - resources_dict = await mcp.get_resources() - templates_dict = await mcp.get_resource_templates() + # Get all components via middleware to respect filtering and preserve metadata + tools_list = await mcp._list_tools_middleware() + prompts_list = await mcp._list_prompts_middleware() + resources_list = await mcp._list_resources_middleware() + templates_list = await mcp._list_resource_templates_middleware() # Extract detailed tool information tool_infos = [] - for key, tool in tools_dict.items(): - # Convert to MCP tool to get input schema - mcp_tool = tool.to_mcp_tool(name=key) + for tool in tools_list: + mcp_tool = tool.to_mcp_tool(name=tool.key) tool_infos.append( ToolInfo( - key=key, - name=tool.name or key, + key=tool.key, + name=tool.name or tool.key, description=tool.description, input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {}, output_schema=tool.output_schema, @@ -132,11 +131,11 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed prompt information prompt_infos = [] - for key, prompt in prompts_dict.items(): + for prompt in prompts_list: prompt_infos.append( PromptInfo( - key=key, - name=prompt.name or key, + key=prompt.key, + name=prompt.name or prompt.key, description=prompt.description, arguments=[arg.model_dump() for arg in prompt.arguments] if prompt.arguments @@ -150,11 +149,11 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed resource information resource_infos = [] - for key, resource in resources_dict.items(): + for resource in resources_list: resource_infos.append( ResourceInfo( - key=key, - uri=key, # For v2, key is the URI + key=resource.key, + uri=resource.key, name=resource.name, description=resource.description, mime_type=resource.mime_type, @@ -170,11 +169,11 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: # Extract detailed template information template_infos = [] - for key, template in templates_dict.items(): + for template in templates_list: template_infos.append( TemplateInfo( - key=key, - uri_template=key, # For v2, key is the URI template + key=template.key, + uri_template=template.key, name=template.name, description=template.description, mime_type=template.mime_type, diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 9a41c7600..009103233 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -941,12 +941,7 @@ class TestInferTransport: transport = infer_transport(config) assert isinstance(transport, MCPConfigTransport) assert isinstance(transport.transport, FastMCPTransport) - assert ( - len( - cast(FastMCP, transport.transport.server)._tool_manager._mounted_servers - ) - == 2 - ) + assert len(cast(FastMCP, transport.transport.server)._mounted_servers) == 2 def test_infer_fastmcp_server(self, fastmcp_server): """FastMCP server instances should infer to FastMCPTransport.""" diff --git a/tests/server/openapi/test_advanced_behavior.py b/tests/server/openapi/test_advanced_behavior.py index 81cd34faf..159c47900 100644 --- a/tests/server/openapi/test_advanced_behavior.py +++ b/tests/server/openapi/test_advanced_behavior.py @@ -102,7 +102,8 @@ 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 = await fastmcp_openapi_server._tool_manager.list_tools() + tools_dict = await fastmcp_openapi_server._tool_manager.get_tools() + tools = list(tools_dict.values()) # Find the create_user and update_user_name tools create_user_tool = next( @@ -201,7 +202,8 @@ class TestReprMethods: async def test_openapi_tool_repr(self, fastmcp_openapi_server: FastMCPOpenAPI): """Test that OpenAPITool's __repr__ method works without recursion errors.""" - tools = await fastmcp_openapi_server._tool_manager.list_tools() + tools_dict = await fastmcp_openapi_server._tool_manager.get_tools() + tools = list(tools_dict.values()) tool = next(iter(tools)) # Verify repr doesn't cause recursion and contains expected elements @@ -276,7 +278,8 @@ class TestEnumHandling: ) # Get the tools from the server - tools = await server._tool_manager.list_tools() + tools_dict = await server._tool_manager.get_tools() + tools = list(tools_dict.values()) # Find the read_item tool read_item_tool = next((t for t in tools if t.name == "read_item_items"), None) diff --git a/tests/server/openapi/test_configuration.py b/tests/server/openapi/test_configuration.py index 8000ac35e..38e2c6f05 100644 --- a/tests/server/openapi/test_configuration.py +++ b/tests/server/openapi/test_configuration.py @@ -65,8 +65,8 @@ class TestRouteMapWildcard: ) # All operations should be mapped to tools - tools = await mcp._tool_manager.list_tools() - tool_names = {tool.name for tool in tools} + tools_dict = await mcp._tool_manager.get_tools() + tool_names = {tool.name for tool in tools_dict.values()} # Check that all 4 operations became tools expected_tools = {"getUsers", "createUser", "getPosts", "createPost"} @@ -382,8 +382,8 @@ class TestMCPNames: ) # Check tools use custom names - tools = await server._tool_manager.list_tools() - tool_names = {tool.name for tool in tools} + tools_dict = await server._tool_manager.get_tools() + tool_names = {tool.name for tool in tools_dict.values()} assert "admin_create_user" in tool_names # Check resource templates use custom names @@ -412,7 +412,8 @@ class TestMCPNames: route_maps=GET_ROUTE_MAPS, ) - tools = await server._tool_manager.list_tools() + tools_dict = await server._tool_manager.get_tools() + tools = list(tools_dict.values()) tool_names = {tool.name for tool in tools} templates_dict = await server._resource_manager.get_resource_templates() @@ -468,8 +469,8 @@ class TestMCPNames: # Check all component types all_names = [] - tools = await server._tool_manager.list_tools() - all_names.extend(tool.name for tool in tools) + tools_dict = await server._tool_manager.get_tools() + all_names.extend(tool.name for tool in tools_dict.values()) resources_dict = await server._resource_manager.get_resources() all_names.extend(resource.name for resource in resources_dict.values()) @@ -501,8 +502,8 @@ class TestMCPNames: mcp_names=mcp_names, ) - tools = await server._tool_manager.list_tools() - tool_names = {tool.name for tool in tools} + tools_dict = await server._tool_manager.get_tools() + tool_names = {tool.name for tool in tools_dict.values()} assert "openapi_user_list" in tool_names async def test_mcp_names_with_from_fastapi_classmethod(self): @@ -533,8 +534,8 @@ class TestMCPNames: mcp_names=mcp_names, ) - tools = await server._tool_manager.list_tools() - tool_names = {tool.name for tool in tools} + tools_dict = await server._tool_manager.get_tools() + tool_names = {tool.name for tool in tools_dict.values()} assert "fastapi_create_user" in tool_names assert "fastapi_user_list" in tool_names @@ -636,7 +637,8 @@ class TestRouteMapMCPTags: ) # Get the POST tool - tools = await server._tool_manager.list_tools() + tools_dict = await server._tool_manager.get_tools() + tools = list(tools_dict.values()) 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" @@ -752,7 +754,8 @@ class TestRouteMapMCPTags: ) # Check tool tags - tools = await server._tool_manager.list_tools() + tools_dict = await server._tool_manager.get_tools() + tools = list(tools_dict.values()) 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 diff --git a/tests/server/openapi/test_description_propagation.py b/tests/server/openapi/test_description_propagation.py index 04bf21c01..a851a19a8 100644 --- a/tests/server/openapi/test_description_propagation.py +++ b/tests/server/openapi/test_description_propagation.py @@ -567,7 +567,8 @@ class TestFastAPIDescriptionPropagation: print(f" Template: {name}, Name attribute: {template.name}") print("\nDEBUG - Tools created:") - tools = await server._tool_manager.list_tools() + tools_dict = await server._tool_manager.get_tools() + tools = list(tools_dict.values()) for tool in tools: print(f" Tool: {tool.name}") diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 8fde83446..e3fd3abd1 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -329,18 +329,15 @@ class TestMultipleServerMount: record.message for record in caplog.records if record.levelname == "WARNING" ] assert any( - "Failed to get tools from server: 'unreachable_proxy', mounted at: 'unreachable'" - in msg + "Failed to list tools from mounted server 'unreachable_proxy'" in msg for msg in warning_messages ) assert any( - "Failed to get resources from server: 'unreachable_proxy', mounted at: 'unreachable'" - in msg + "Failed to list resources from 'unreachable_proxy'" in msg for msg in warning_messages ) assert any( - "Failed to get prompts from server: 'unreachable_proxy', mounted at: 'unreachable'" - in msg + "Failed to list prompts from mounted server 'unreachable_proxy'" in msg for msg in warning_messages ) @@ -871,7 +868,7 @@ class TestAsProxyKwarg: sub = FastMCP("Sub") mcp.mount(sub, "sub") - assert mcp._tool_manager._mounted_servers[0].server is sub + assert mcp._mounted_servers[0].server is sub async def test_as_proxy_false(self): mcp = FastMCP("Main") @@ -879,7 +876,7 @@ class TestAsProxyKwarg: mcp.mount(sub, "sub", as_proxy=False) - assert mcp._tool_manager._mounted_servers[0].server is sub + assert mcp._mounted_servers[0].server is sub async def test_as_proxy_true(self): mcp = FastMCP("Main") @@ -887,8 +884,8 @@ class TestAsProxyKwarg: mcp.mount(sub, "sub", as_proxy=True) - assert mcp._tool_manager._mounted_servers[0].server is not sub - assert isinstance(mcp._tool_manager._mounted_servers[0].server, FastMCPProxy) + assert mcp._mounted_servers[0].server is not sub + assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy) async def test_as_proxy_defaults_true_if_lifespan(self): @asynccontextmanager @@ -900,8 +897,8 @@ class TestAsProxyKwarg: mcp.mount(sub, "sub") - assert mcp._tool_manager._mounted_servers[0].server is not sub - assert isinstance(mcp._tool_manager._mounted_servers[0].server, FastMCPProxy) + assert mcp._mounted_servers[0].server is not sub + assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy) async def test_as_proxy_ignored_for_proxy_mounts_default(self): mcp = FastMCP("Main") @@ -910,7 +907,7 @@ class TestAsProxyKwarg: mcp.mount(sub_proxy, "sub") - assert mcp._tool_manager._mounted_servers[0].server is sub_proxy + assert mcp._mounted_servers[0].server is sub_proxy async def test_as_proxy_ignored_for_proxy_mounts_false(self): mcp = FastMCP("Main") @@ -919,7 +916,7 @@ class TestAsProxyKwarg: mcp.mount(sub_proxy, "sub", as_proxy=False) - assert mcp._tool_manager._mounted_servers[0].server is sub_proxy + assert mcp._mounted_servers[0].server is sub_proxy async def test_as_proxy_ignored_for_proxy_mounts_true(self): mcp = FastMCP("Main") @@ -928,7 +925,7 @@ class TestAsProxyKwarg: mcp.mount(sub_proxy, "sub", as_proxy=True) - assert mcp._tool_manager._mounted_servers[0].server is sub_proxy + assert mcp._mounted_servers[0].server is sub_proxy async def test_as_proxy_mounts_still_have_live_link(self): mcp = FastMCP("Main") @@ -1024,6 +1021,101 @@ class TestResourceNamePrefixing: assert template.name == "prefix_user_template" +class TestParentTagFiltering: + """Test that parent server tag filters apply recursively to mounted servers.""" + + async def test_parent_include_tags_filters_mounted_tools(self): + """Test that parent include_tags filters out non-matching mounted tools.""" + parent = FastMCP("Parent", include_tags={"allowed"}) + mounted = FastMCP("Mounted") + + @mounted.tool(tags={"allowed"}) + def allowed_tool() -> str: + return "allowed" + + @mounted.tool(tags={"blocked"}) + def blocked_tool() -> str: + return "blocked" + + parent.mount(mounted) + + async with Client(parent) as client: + tools = await client.list_tools() + tool_names = {t.name for t in tools} + assert "allowed_tool" in tool_names + assert "blocked_tool" not in tool_names + + # Verify execution also respects filters + result = await client.call_tool("allowed_tool", {}) + assert result.data == "allowed" + + with pytest.raises(Exception, match="Unknown tool"): + await client.call_tool("blocked_tool", {}) + + async def test_parent_exclude_tags_filters_mounted_tools(self): + """Test that parent exclude_tags filters out matching mounted tools.""" + parent = FastMCP("Parent", exclude_tags={"blocked"}) + mounted = FastMCP("Mounted") + + @mounted.tool(tags={"production"}) + def production_tool() -> str: + return "production" + + @mounted.tool(tags={"blocked"}) + def blocked_tool() -> str: + return "blocked" + + parent.mount(mounted) + + async with Client(parent) as client: + tools = await client.list_tools() + tool_names = {t.name for t in tools} + assert "production_tool" in tool_names + assert "blocked_tool" not in tool_names + + async def test_parent_filters_apply_to_mounted_resources(self): + """Test that parent tag filters apply to mounted resources.""" + parent = FastMCP("Parent", include_tags={"allowed"}) + mounted = FastMCP("Mounted") + + @mounted.resource("resource://allowed", tags={"allowed"}) + def allowed_resource() -> str: + return "allowed" + + @mounted.resource("resource://blocked", tags={"blocked"}) + def blocked_resource() -> str: + return "blocked" + + parent.mount(mounted) + + async with Client(parent) as client: + resources = await client.list_resources() + resource_uris = {str(r.uri) for r in resources} + assert "resource://allowed" in resource_uris + assert "resource://blocked" not in resource_uris + + async def test_parent_filters_apply_to_mounted_prompts(self): + """Test that parent tag filters apply to mounted prompts.""" + parent = FastMCP("Parent", exclude_tags={"blocked"}) + mounted = FastMCP("Mounted") + + @mounted.prompt(tags={"allowed"}) + def allowed_prompt() -> str: + return "allowed" + + @mounted.prompt(tags={"blocked"}) + def blocked_prompt() -> str: + return "blocked" + + parent.mount(mounted) + + async with Client(parent) as client: + prompts = await client.list_prompts() + prompt_names = {p.name for p in prompts} + assert "allowed_prompt" in prompt_names + assert "blocked_prompt" not in prompt_names + + class TestCustomRouteForwarding: """Test that custom HTTP routes from mounted servers are forwarded.""" diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 819cb2146..1b712be81 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -307,10 +307,11 @@ class TestToolDecorator: def sample_tool(x: int) -> int: return x * 2 - # Verify the tags were set correctly - tools = await mcp._tool_manager.list_tools() - assert len(tools) == 1 - assert tools[0].tags == {"example", "test-tag"} + # Verify the tags were set correctly (local inventory) + tools_dict = await mcp._tool_manager.get_tools() + assert len(tools_dict) == 1 + only_tool = next(iter(tools_dict.values())) + assert only_tool.tags == {"example", "test-tag"} async def test_add_tool_with_custom_name(self): """Test adding a tool with a custom name using server.add_tool().""" diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 10d0b7972..e39e0a7b8 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -280,8 +280,8 @@ class TestListTools: tool_manager.add_tool_transformation( "add", ToolTransformConfig(name="add_transformed") ) - tools = await tool_manager.list_tools() - tools_by_name = {tool.name: tool for tool in tools} + tools_dict = await tool_manager.get_tools() + tools_by_name = {tool.name: tool for tool in tools_dict.values()} assert "add_transformed" in tools_by_name assert "add" not in tools_by_name @@ -303,8 +303,8 @@ class TestListTools: name="add_transformed", description=None, tags={"enabled_tools"} ), ) - tools = await tool_manager.list_tools() - tools_by_name = {tool.name: tool for tool in tools} + tools_dict = await tool_manager.get_tools() + tools_by_name = {tool.name: tool for tool in tools_dict.values()} assert "add_transformed" in tools_by_name assert "add" not in tools_by_name assert tools_by_name["add_transformed"].description is None @@ -1027,12 +1027,12 @@ class TestMountedComponentsRaiseOnLoadError: # Create a failing mounted server by corrupting it parent_mcp.mount(child_mcp, prefix="child") - # Corrupt the child server to make it fail during tool loading - child_mcp._tool_manager._mounted_servers.append("invalid") # type: ignore + # Corrupt the parent's mounted servers to make it fail during loading + parent_mcp._mounted_servers.append("invalid") # type: ignore - # Should not raise, just warn - tools = await parent_mcp._tool_manager.list_tools() - assert isinstance(tools, list) # Should return empty list, not raise + # Should not raise, just warn; use server middleware path now + tools = await parent_mcp._list_tools_middleware() + assert isinstance(tools, list) # Should return list, not raise async def test_mounted_components_raise_on_load_error_true(self): """Test that when enabled, mounted component load errors are raised.""" @@ -1041,8 +1041,8 @@ class TestMountedComponentsRaiseOnLoadError: # Create a failing mounted server parent_mcp.mount(child_mcp, prefix="child") - # Corrupt the child server to make it fail during tool loading - child_mcp._tool_manager._mounted_servers.append("invalid") # type: ignore + # Corrupt the parent's mounted servers to make it fail during loading + parent_mcp._mounted_servers.append("invalid") # type: ignore # Use temporary settings context manager with temporary_settings(mounted_components_raise_on_load_error=True): @@ -1050,4 +1050,4 @@ class TestMountedComponentsRaiseOnLoadError: with pytest.raises( AttributeError, match="'str' object has no attribute 'server'" ): - await parent_mcp._tool_manager.list_tools() + await parent_mcp._list_tools_middleware() diff --git a/tests/utilities/test_inspect.py b/tests/utilities/test_inspect.py index 420335c82..730e297b6 100644 --- a/tests/utilities/test_inspect.py +++ b/tests/utilities/test_inspect.py @@ -269,6 +269,198 @@ class TestGetFastMCPInfo: assert info.resources[0].uri == str(resources[0].uri) assert info.prompts[0].name == prompts[0].name + async def test_inspect_respects_tag_filtering(self): + """Test that inspect omits components filtered out by include_tags/exclude_tags. + + Regression test for Issue #2032: inspect command was showing components + that were filtered out by tag rules, causing confusion when those + components weren't actually available to clients. + """ + # Create server with include_tags that will filter out untagged components + mcp = FastMCP( + "FilteredServer", + include_tags={"fetch", "analyze", "create"}, + ) + + # Add tools with and without matching tags + @mcp.tool(tags={"fetch"}) + def tagged_tool() -> str: + """Tool with matching tag - should be visible.""" + return "visible" + + @mcp.tool + def untagged_tool() -> str: + """Tool without tags - should be filtered out.""" + return "hidden" + + # Add resources with and without matching tags + @mcp.resource("resource://tagged", tags={"analyze"}) + def tagged_resource() -> str: + """Resource with matching tag - should be visible.""" + return "visible resource" + + @mcp.resource("resource://untagged") + def untagged_resource() -> str: + """Resource without tags - should be filtered out.""" + return "hidden resource" + + # Add templates with and without matching tags + @mcp.resource("resource://tagged/{id}", tags={"create"}) + def tagged_template(id: str) -> str: + """Template with matching tag - should be visible.""" + return f"visible template {id}" + + @mcp.resource("resource://untagged/{id}") + def untagged_template(id: str) -> str: + """Template without tags - should be filtered out.""" + return f"hidden template {id}" + + # Add prompts with and without matching tags + @mcp.prompt(tags={"fetch"}) + def tagged_prompt() -> list: + """Prompt with matching tag - should be visible.""" + return [{"role": "user", "content": "visible prompt"}] + + @mcp.prompt + def untagged_prompt() -> list: + """Prompt without tags - should be filtered out.""" + return [{"role": "user", "content": "hidden prompt"}] + + # Get inspect info + info = await inspect_fastmcp(mcp) + + # Verify only tagged components are visible + assert len(info.tools) == 1 + assert info.tools[0].name == "tagged_tool" + + assert len(info.resources) == 1 + assert info.resources[0].uri == "resource://tagged" + + assert len(info.templates) == 1 + assert info.templates[0].uri_template == "resource://tagged/{id}" + + assert len(info.prompts) == 1 + assert info.prompts[0].name == "tagged_prompt" + + # Verify this matches what a client would see + async with Client(mcp) as client: + tools = await client.list_tools() + resources = await client.list_resources() + templates = await client.list_resource_templates() + prompts = await client.list_prompts() + + assert len(info.tools) == len(tools) + assert len(info.resources) == len(resources) + assert len(info.templates) == len(templates) + assert len(info.prompts) == len(prompts) + + async def test_inspect_respects_tag_filtering_with_mounted_servers(self): + """Test that inspect applies tag filtering to mounted servers. + + Verifies that when a parent server has tag filters, those filters + are respected when inspecting components from mounted servers. + """ + # Create a mounted server with various tagged and untagged components + mounted = FastMCP("MountedServer") + + @mounted.tool(tags={"allowed"}) + def allowed_tool() -> str: + return "allowed" + + @mounted.tool(tags={"blocked"}) + def blocked_tool() -> str: + return "blocked" + + @mounted.tool + def untagged_tool() -> str: + return "untagged" + + @mounted.resource("resource://allowed", tags={"allowed"}) + def allowed_resource() -> str: + return "allowed resource" + + @mounted.resource("resource://blocked", tags={"blocked"}) + def blocked_resource() -> str: + return "blocked resource" + + @mounted.prompt(tags={"allowed"}) + def allowed_prompt() -> list: + return [{"role": "user", "content": "allowed"}] + + @mounted.prompt(tags={"blocked"}) + def blocked_prompt() -> list: + return [{"role": "user", "content": "blocked"}] + + # Create parent server with tag filtering + parent = FastMCP("ParentServer", include_tags={"allowed"}) + parent.mount(mounted) + + # Get inspect info + info = await inspect_fastmcp(parent) + + # Only components with "allowed" tag should be visible + tool_names = [t.name for t in info.tools] + assert "allowed_tool" in tool_names + assert "blocked_tool" not in tool_names + assert "untagged_tool" not in tool_names + + resource_uris = [r.uri for r in info.resources] + assert "resource://allowed" in resource_uris + assert "resource://blocked" not in resource_uris + + prompt_names = [p.name for p in info.prompts] + assert "allowed_prompt" in prompt_names + assert "blocked_prompt" not in prompt_names + + # Verify this matches what a client would see + async with Client(parent) as client: + tools = await client.list_tools() + resources = await client.list_resources() + prompts = await client.list_prompts() + + assert len(info.tools) == len(tools) + assert len(info.resources) == len(resources) + assert len(info.prompts) == len(prompts) + + async def test_inspect_parent_filters_override_mounted_server_filters(self): + """Test that parent server tag filters apply to mounted servers. + + Even if a mounted server has no tag filters of its own, + the parent server's filters should still apply. + """ + # Create mounted server with NO tag filters (allows everything) + mounted = FastMCP("MountedServer") + + @mounted.tool(tags={"production"}) + def production_tool() -> str: + return "production" + + @mounted.tool(tags={"development"}) + def development_tool() -> str: + return "development" + + @mounted.tool + def untagged_tool() -> str: + return "untagged" + + # Create parent with exclude_tags - should filter mounted components + parent = FastMCP("ParentServer", exclude_tags={"development"}) + parent.mount(mounted) + + # Get inspect info + info = await inspect_fastmcp(parent) + + # Only production and untagged should be visible + tool_names = [t.name for t in info.tools] + assert "production_tool" in tool_names + assert "untagged_tool" in tool_names + assert "development_tool" not in tool_names + + # Verify this matches what a client would see + async with Client(parent) as client: + tools = await client.list_tools() + assert len(info.tools) == len(tools) + class TestFastMCP1xCompatibility: """Tests for FastMCP 1.x compatibility."""