Refactor mounting logic from managers to server (#2069)

This commit is contained in:
Jeremiah Lowin 2025-10-11 18:28:21 -04:00 committed by GitHub
commit 1f11b5e641
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 840 additions and 525 deletions

6
.cursor/worktrees.json Normal file
View file

@ -0,0 +1,6 @@
{
"setup-worktree": [
"uv sync",
"uv run pre-commit install"
]
}

View file

@ -263,6 +263,64 @@ main_server.mount(remote_proxy, prefix="remote")
## Tag Filtering with Composition
<VersionBadge version="2.9.0" />
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.
<Note>
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.
</Note>
## Resource Prefix Formats
<VersionBadge version="2.4.0" />

View file

@ -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}_")

View file

@ -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

View file

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

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

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

View file

@ -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)

View file

@ -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

View file

@ -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}")

View file

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

View file

@ -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()."""

View file

@ -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()

View file

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