Spaces:
Running
Running
File size: 9,186 Bytes
816878f d4adea4 a853a78 48f7143 a32223b 48f7143 7974ade 48f7143 927e044 2c75133 fa364d7 7974ade bacf327 d4adea4 48f7143 816878f 48f7143 d4adea4 48f7143 ee0baf8 927e044 bacf327 ee0baf8 48f7143 506c09a 927e044 bacf327 2c1fc32 fa364d7 48f7143 816878f 506c09a 816878f a32223b 816878f a32223b 816878f a32223b 816878f 506c09a 816878f a32223b 816878f a32223b 09cabfa 816878f d37c5e4 816878f 8d16d26 816878f 27106b0 816878f 28c234f 816878f bacf327 816878f 72f52ef 816878f 72f52ef 816878f 24b96b7 816878f 48f7143 816878f a32223b 736b52b 09cabfa 816878f a32223b 816878f 48f7143 bacf327 fa364d7 48f7143 316c336 c2a9892 4757dcb c5ee465 48f7143 a853a78 905120e 873479e c2a9892 c5ee465 4757dcb c2a9892 fa364d7 316c336 09cabfa 316c336 09cabfa 48f7143 2c1fc32 09cabfa 2c1fc32 09cabfa 2c1fc32 09cabfa 2c1fc32 09cabfa 48f7143 bacf327 c44bbc1 816878f c44bbc1 7974ade 816878f bacf327 816878f 7d648cc 816878f 7d648cc 816878f 506c09a 816878f 506c09a 816878f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | from __future__ import annotations
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from mcp.types import ToolAnnotations
from fastmcp import settings
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.settings import DuplicateBehavior
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.tools.tool_transform import (
ToolTransformConfig,
apply_transformations_to_tools,
)
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from fastmcp.server.server import MountedServer
logger = get_logger(__name__)
class ToolManager:
"""Manages FastMCP tools."""
def __init__(
self,
duplicate_behavior: DuplicateBehavior | None = None,
mask_error_details: bool | None = None,
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 {}
# Default to "warn" if None is provided
if duplicate_behavior is None:
duplicate_behavior = "warn"
if duplicate_behavior not in DuplicateBehavior.__args__:
raise ValueError(
f"Invalid duplicate_behavior: {duplicate_behavior}. "
f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
)
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, *, via_server: bool = False) -> dict[str, Tool]:
"""
The single, consolidated recursive method for fetching tools. The 'via_server'
parameter determines the communication path.
- via_server=False: Manager-to-manager path for complete, unfiltered inventory
- via_server=True: Server-to-server path for filtered MCP requests
"""
all_tools: dict[str, Tool] = {}
for mounted in self._mounted_servers:
try:
if via_server:
# Use the server-to-server filtered path
child_results = await mounted.server._list_tools()
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)
transformed_tools = apply_transformations_to_tools(
tools=all_tools,
transformations=self.transformations,
)
return transformed_tools
async def has_tool(self, key: str) -> bool:
"""Check if a tool exists."""
tools = await self.get_tools()
return key in tools
async def get_tool(self, key: str) -> Tool:
"""Get tool by key."""
tools = await self.get_tools()
if key in tools:
return tools[key]
raise NotFoundError(f"Tool {key!r} not found")
async def get_tools(self) -> dict[str, Tool]:
"""
Gets the complete, unfiltered inventory of all tools.
"""
return await self._load_tools(via_server=False)
async def list_tools(self) -> list[Tool]:
"""
Lists all tools, applying protocol filtering.
"""
tools_dict = await self._load_tools(via_server=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()
]
def add_tool_from_fn(
self,
fn: Callable[..., Any],
name: str | None = None,
description: str | None = None,
tags: set[str] | None = None,
annotations: ToolAnnotations | None = None,
serializer: Callable[[Any], str] | None = None,
exclude_args: list[str] | None = None,
) -> Tool:
"""Add a tool to the server."""
# deprecated in 2.7.0
if settings.deprecation_warnings:
warnings.warn(
"ToolManager.add_tool_from_fn() is deprecated. Use Tool.from_function() and call add_tool() instead.",
DeprecationWarning,
stacklevel=2,
)
tool = Tool.from_function(
fn,
name=name,
description=description,
tags=tags,
annotations=annotations,
exclude_args=exclude_args,
serializer=serializer,
)
return self.add_tool(tool)
def add_tool(self, tool: Tool) -> Tool:
"""Register a tool with the server."""
existing = self._tools.get(tool.key)
if existing:
if self.duplicate_behavior == "warn":
logger.warning(f"Tool already exists: {tool.key}")
self._tools[tool.key] = tool
elif self.duplicate_behavior == "replace":
self._tools[tool.key] = tool
elif self.duplicate_behavior == "error":
raise ValueError(f"Tool already exists: {tool.key}")
elif self.duplicate_behavior == "ignore":
return existing
else:
self._tools[tool.key] = tool
return tool
def add_tool_transformation(
self, tool_name: str, transformation: ToolTransformConfig
) -> None:
"""Add a tool transformation."""
self.transformations[tool_name] = transformation
def get_tool_transformation(self, tool_name: str) -> ToolTransformConfig | None:
"""Get a tool transformation."""
return self.transformations.get(tool_name)
def remove_tool_transformation(self, tool_name: str) -> None:
"""Remove a tool transformation."""
if tool_name in self.transformations:
del self.transformations[tool_name]
def remove_tool(self, key: str) -> None:
"""Remove a tool from the server.
Args:
key: The key of the tool to remove
Raises:
NotFoundError: If the tool is not found
"""
if key in self._tools:
del self._tools[key]
else:
raise NotFoundError(f"Tool {key!r} not found")
async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
"""
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(tool_key, arguments)
except NotFoundError:
continue
raise NotFoundError(f"Tool {key!r} not found.")
|