mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 21:14:17 +02:00
Refactor mounting logic from managers to server (#2069)
This commit is contained in:
parent
c42c2b937e
commit
1f11b5e641
16 changed files with 840 additions and 525 deletions
6
.cursor/worktrees.json
Normal file
6
.cursor/worktrees.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"setup-worktree": [
|
||||
"uv sync",
|
||||
"uv run pre-commit install"
|
||||
]
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}_")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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()."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue