Update tool manager

This commit is contained in:
Jeremiah Lowin 2025-06-19 11:04:50 -04:00
commit 607125abf8
3 changed files with 182 additions and 171 deletions

View file

@ -434,10 +434,10 @@ class FastMCP(Generic[LifespanResultT]):
logger.debug("Handler called: list_tools")
with fastmcp.server.context.Context(fastmcp=self):
tools = await self._middleware_list_tools()
tools = await self._list_tools()
return [tool.to_mcp_tool(name=tool.key) for tool in tools]
async def _middleware_list_tools(self) -> list[Tool]:
async def _list_tools(self) -> list[Tool]:
"""
List all available tools, in the format expected by the low-level MCP
server.
@ -447,7 +447,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _handler(
context: MiddlewareContext[mcp.types.ListToolsRequest],
) -> list[Tool]:
tools = await self._list_tools()
tools = await self._tool_manager.list_tools()
mcp_tools: list[Tool] = []
for tool in tools:
@ -469,39 +469,6 @@ class FastMCP(Generic[LifespanResultT]):
# Apply the middleware chain.
return await self._apply_middleware(mw_context, _handler)
async def _list_tools(self, apply_middleware: bool = True) -> list[Tool]:
"""
List all available tools.
"""
if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
tools: dict[str, Tool] = {}
# iterate such that new mounts overwrite older ones
for mounted_server in self._mounted_servers:
try:
if apply_middleware:
server_tools = (
await mounted_server.server._middleware_list_tools()
)
else:
server_tools = await mounted_server.server._list_tools()
# Apply prefix to each tool key if prefix exists and is not empty
if mounted_server.prefix:
for tool in server_tools:
tool = tool.with_key(f"{mounted_server.prefix}_{tool.key}")
tools[tool.key] = tool
else:
tools.update({tool.key: tool for tool in server_tools})
except Exception as e:
logger.warning(
f"Failed to get tools from mounted server '{mounted_server.prefix}': {e}"
)
continue
tools.update(self._tool_manager.get_tools())
self._cache.set("tools", tools)
return list(tools.values())
async def _mcp_list_resources(self) -> list[MCPResource]:
logger.debug("Handler called: list_resources")
@ -580,7 +547,11 @@ class FastMCP(Generic[LifespanResultT]):
f"Failed to get resources from mounted server '{mounted_server.prefix}': {e}"
)
continue
resources.update(self._resource_manager.get_resources())
(
local_resources,
_,
) = await self._resource_manager.get_resources_and_templates()
resources.update(local_resources)
self._cache.set("resources", resources)
return list(resources.values())
@ -668,7 +639,11 @@ class FastMCP(Generic[LifespanResultT]):
f"'{mounted_server.prefix}': {e}"
)
continue
templates.update(self._resource_manager.get_templates())
(
_,
local_templates,
) = await self._resource_manager.get_resources_and_templates()
templates.update(local_templates)
self._cache.set("resource_templates", templates)
return list(templates.values())
@ -744,7 +719,7 @@ class FastMCP(Generic[LifespanResultT]):
f"Failed to get prompts from mounted server '{mounted_server.prefix}': {e}"
)
continue
prompts.update(self._prompt_manager.get_prompts())
prompts.update(await self._prompt_manager.get_prompts())
self._cache.set("prompts", prompts)
return list(prompts.values())
@ -767,27 +742,26 @@ class FastMCP(Generic[LifespanResultT]):
with fastmcp.server.context.Context(fastmcp=self):
try:
return await self._middleware_call_tool(key, arguments)
return await self._call_tool(key, arguments)
except DisabledError:
raise NotFoundError(f"Unknown tool: {key}")
except NotFoundError:
raise NotFoundError(f"Unknown tool: {key}")
async def _middleware_call_tool(
self,
key: str,
arguments: dict[str, Any],
) -> list[MCPContent]:
async def _call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
"""
Call a tool with middleware.
Applies this server's middleware and delegates the filtered call to the manager.
"""
async def _handler(
context: MiddlewareContext[mcp.types.CallToolRequestParams],
) -> list[MCPContent]:
return await self._call_tool(
key=context.message.name,
arguments=context.message.arguments or {},
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}")
return await self._tool_manager.call_tool(
key=context.message.name, arguments=context.message.arguments or {}
)
mw_context = MiddlewareContext(
@ -799,46 +773,6 @@ class FastMCP(Generic[LifespanResultT]):
)
return await self._apply_middleware(mw_context, _handler)
async def _call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
"""
Call a tool with raw MCP arguments. FastMCP subclasses should override
this method, not _mcp_call_tool.
Args:
key: The name of the tool to call arguments: Arguments to pass to
the tool
Returns:
List of MCP Content objects containing the tool results
"""
# Get tool, checking first from our tools, then from the mounted servers
if self._tool_manager.has_tool(key):
tool = self._tool_manager.get_tool(key)
if not self._should_enable_component(tool):
raise DisabledError(f"Tool {key!r} is disabled")
return await self._tool_manager.call_tool(key, arguments)
# Check mounted servers to see if they have the tool
# iterate such that new mounts take precedence over older ones
for mounted_server in reversed(self._mounted_servers):
tool_key = key
try:
# If server has a prefix, check if key matches and strip prefix
if mounted_server.prefix:
if tool_key.startswith(f"{mounted_server.prefix}_"):
tool_key = tool_key.removeprefix(f"{mounted_server.prefix}_")
else:
continue
return await mounted_server.server._middleware_call_tool(
tool_key, arguments
)
except NotFoundError:
# Tool not found on this server, try the next one
continue
raise NotFoundError(f"Unknown tool: {key!r}")
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
"""
Handle MCP 'readResource' requests.
@ -1853,11 +1787,12 @@ class FastMCP(Generic[LifespanResultT]):
if as_proxy and not isinstance(server, FastMCPProxy):
server = FastMCPProxy(Client(transport=FastMCPTransport(server)))
mounted_server = MountedServer(
server=server,
prefix=prefix,
)
self._mounted_servers.append(mounted_server)
# Delegate mounting to all three managers
mounted_server = MountedServer(prefix=prefix, server=server)
self._tool_manager.mount(mounted_server)
self._resource_manager.mount(mounted_server)
self._prompt_manager.mount(mounted_server)
self._cache.clear()
async def import_server(

View file

@ -1,8 +1,8 @@
from __future__ import annotations as _annotations
from __future__ import annotations
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from mcp.types import ToolAnnotations
@ -14,7 +14,7 @@ from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import MCPContent
if TYPE_CHECKING:
pass
from fastmcp.server.server import MountedServer
logger = get_logger(__name__)
@ -28,6 +28,7 @@ class ToolManager:
mask_error_details: bool | None = None,
):
self._tools: dict[str, Tool] = {}
self._mounted_sources: list[MountedServer] = []
self.mask_error_details = mask_error_details or settings.mask_error_details
# Default to "warn" if None is provided
@ -42,23 +43,78 @@ class ToolManager:
self.duplicate_behavior = duplicate_behavior
def has_tool(self, key: str) -> bool:
def mount(self, server: MountedServer) -> None:
"""Adds a mounted server as a source for tools."""
self._mounted_sources.append(server)
async def _load_tools(
self, *, mode: Literal["inventory", "protocol"]
) -> dict[str, Tool]:
"""
The single, consolidated recursive method for fetching tools. The 'mode'
parameter determines the communication path.
- mode="inventory": Manager-to-manager path for complete, unfiltered inventory
- mode="protocol": Server-to-server path for filtered MCP requests
"""
all_tools: dict[str, Tool] = {}
for mounted in self._mounted_sources:
try:
if mode == "protocol":
# PATH 2: Use the server-to-server filtered path
child_results = await mounted.server._list_tools()
else: # mode == "inventory"
# PATH 1: Use the manager-to-manager unfiltered path
child_results = await mounted.server._tool_manager.get_tools()
# The combination logic is the same for both paths
child_dict = (
{t.key: t for t in child_results}
if isinstance(child_results, list)
else child_results
)
if mounted.prefix:
for tool in child_dict.values():
prefixed_tool = tool.with_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 mounted server '{mounted.prefix}': {e}"
)
continue
# Finally, add local tools, which always take precedence
all_tools.update(self._tools)
return all_tools
async def has_tool(self, key: str) -> bool:
"""Check if a tool exists."""
return key in self._tools
tools = await self.get_tools()
return key in tools
def get_tool(self, key: str) -> Tool:
async def get_tool(self, key: str) -> Tool:
"""Get tool by key."""
if key in self._tools:
return self._tools[key]
raise NotFoundError(f"Unknown tool: {key}")
tools = await self.get_tools()
if key in tools:
return tools[key]
raise NotFoundError(f"Tool {key!r} not found")
def get_tools(self) -> dict[str, Tool]:
"""Get all registered tools, indexed by registered key."""
return self._tools
async def get_tools(self) -> dict[str, Tool]:
"""
Gets the complete, unfiltered inventory of all tools.
"""
return await self._load_tools(mode="inventory")
def list_tools(self) -> list[Tool]:
"""List all registered tools."""
return list(self.get_tools().values())
async def list_tools(self) -> list[Tool]:
"""
Lists all tools, applying protocol filtering.
"""
tools_dict = await self._load_tools(mode="protocol")
return list(tools_dict.values())
def add_tool_from_fn(
self,
@ -119,28 +175,44 @@ class ToolManager:
if key in self._tools:
del self._tools[key]
else:
raise NotFoundError(f"Unknown tool: {key}")
raise NotFoundError(f"Tool {key!r} not found")
async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
"""Call a tool by name with arguments."""
tool = self.get_tool(key)
if not tool:
raise NotFoundError(f"Unknown tool: {key}")
"""
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:
tool = await self.get_tool(key)
if not tool:
raise NotFoundError(f"Tool {key!r} not found")
try:
return await tool.run(arguments)
try:
return await tool.run(arguments)
# raise ToolErrors as-is
except ToolError as e:
logger.exception(f"Error calling tool {key!r}: {e}")
raise e
# raise ToolErrors as-is
except ToolError as e:
logger.exception(f"Error calling tool {key!r}: {e}")
raise e
# Handle other exceptions
except Exception as e:
logger.exception(f"Error calling tool {key!r}: {e}")
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
# Handle other exceptions
except Exception as e:
logger.exception(f"Error calling tool {key!r}: {e}")
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_sources):
if mounted.prefix and key.startswith(f"{mounted.prefix}_"):
key_on_child = key.removeprefix(f"{mounted.prefix}_")
try:
return await mounted.server._call_tool(key_on_child, arguments)
except NotFoundError:
continue
raise NotFoundError(f"Tool {key!r} not found.")