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."""