mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-27 15:51:33 +02:00
Update tool manager
This commit is contained in:
parent
cafa11d3ad
commit
607125abf8
3 changed files with 182 additions and 171 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue