Jeremiah Lowin commited on
Commit
816878f
·
1 Parent(s): e2f3152

Update tool manager

Browse files
src/fastmcp/server/server.py CHANGED
@@ -434,10 +434,10 @@ class FastMCP(Generic[LifespanResultT]):
434
  logger.debug("Handler called: list_tools")
435
 
436
  with fastmcp.server.context.Context(fastmcp=self):
437
- tools = await self._middleware_list_tools()
438
  return [tool.to_mcp_tool(name=tool.key) for tool in tools]
439
 
440
- async def _middleware_list_tools(self) -> list[Tool]:
441
  """
442
  List all available tools, in the format expected by the low-level MCP
443
  server.
@@ -447,7 +447,7 @@ class FastMCP(Generic[LifespanResultT]):
447
  async def _handler(
448
  context: MiddlewareContext[mcp.types.ListToolsRequest],
449
  ) -> list[Tool]:
450
- tools = await self._list_tools()
451
 
452
  mcp_tools: list[Tool] = []
453
  for tool in tools:
@@ -469,39 +469,6 @@ class FastMCP(Generic[LifespanResultT]):
469
  # Apply the middleware chain.
470
  return await self._apply_middleware(mw_context, _handler)
471
 
472
- async def _list_tools(self, apply_middleware: bool = True) -> list[Tool]:
473
- """
474
- List all available tools.
475
- """
476
-
477
- if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
478
- tools: dict[str, Tool] = {}
479
-
480
- # iterate such that new mounts overwrite older ones
481
- for mounted_server in self._mounted_servers:
482
- try:
483
- if apply_middleware:
484
- server_tools = (
485
- await mounted_server.server._middleware_list_tools()
486
- )
487
- else:
488
- server_tools = await mounted_server.server._list_tools()
489
- # Apply prefix to each tool key if prefix exists and is not empty
490
- if mounted_server.prefix:
491
- for tool in server_tools:
492
- tool = tool.with_key(f"{mounted_server.prefix}_{tool.key}")
493
- tools[tool.key] = tool
494
- else:
495
- tools.update({tool.key: tool for tool in server_tools})
496
- except Exception as e:
497
- logger.warning(
498
- f"Failed to get tools from mounted server '{mounted_server.prefix}': {e}"
499
- )
500
- continue
501
- tools.update(self._tool_manager.get_tools())
502
- self._cache.set("tools", tools)
503
- return list(tools.values())
504
-
505
  async def _mcp_list_resources(self) -> list[MCPResource]:
506
  logger.debug("Handler called: list_resources")
507
 
@@ -580,7 +547,11 @@ class FastMCP(Generic[LifespanResultT]):
580
  f"Failed to get resources from mounted server '{mounted_server.prefix}': {e}"
581
  )
582
  continue
583
- resources.update(self._resource_manager.get_resources())
 
 
 
 
584
  self._cache.set("resources", resources)
585
  return list(resources.values())
586
 
@@ -668,7 +639,11 @@ class FastMCP(Generic[LifespanResultT]):
668
  f"'{mounted_server.prefix}': {e}"
669
  )
670
  continue
671
- templates.update(self._resource_manager.get_templates())
 
 
 
 
672
  self._cache.set("resource_templates", templates)
673
  return list(templates.values())
674
 
@@ -744,7 +719,7 @@ class FastMCP(Generic[LifespanResultT]):
744
  f"Failed to get prompts from mounted server '{mounted_server.prefix}': {e}"
745
  )
746
  continue
747
- prompts.update(self._prompt_manager.get_prompts())
748
  self._cache.set("prompts", prompts)
749
  return list(prompts.values())
750
 
@@ -767,27 +742,26 @@ class FastMCP(Generic[LifespanResultT]):
767
 
768
  with fastmcp.server.context.Context(fastmcp=self):
769
  try:
770
- return await self._middleware_call_tool(key, arguments)
771
  except DisabledError:
772
  raise NotFoundError(f"Unknown tool: {key}")
773
  except NotFoundError:
774
  raise NotFoundError(f"Unknown tool: {key}")
775
 
776
- async def _middleware_call_tool(
777
- self,
778
- key: str,
779
- arguments: dict[str, Any],
780
- ) -> list[MCPContent]:
781
  """
782
- Call a tool with middleware.
783
  """
784
 
785
  async def _handler(
786
  context: MiddlewareContext[mcp.types.CallToolRequestParams],
787
  ) -> list[MCPContent]:
788
- return await self._call_tool(
789
- key=context.message.name,
790
- arguments=context.message.arguments or {},
 
 
 
791
  )
792
 
793
  mw_context = MiddlewareContext(
@@ -799,46 +773,6 @@ class FastMCP(Generic[LifespanResultT]):
799
  )
800
  return await self._apply_middleware(mw_context, _handler)
801
 
802
- async def _call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
803
- """
804
- Call a tool with raw MCP arguments. FastMCP subclasses should override
805
- this method, not _mcp_call_tool.
806
-
807
- Args:
808
- key: The name of the tool to call arguments: Arguments to pass to
809
- the tool
810
-
811
- Returns:
812
- List of MCP Content objects containing the tool results
813
- """
814
-
815
- # Get tool, checking first from our tools, then from the mounted servers
816
- if self._tool_manager.has_tool(key):
817
- tool = self._tool_manager.get_tool(key)
818
- if not self._should_enable_component(tool):
819
- raise DisabledError(f"Tool {key!r} is disabled")
820
- return await self._tool_manager.call_tool(key, arguments)
821
-
822
- # Check mounted servers to see if they have the tool
823
- # iterate such that new mounts take precedence over older ones
824
- for mounted_server in reversed(self._mounted_servers):
825
- tool_key = key
826
- try:
827
- # If server has a prefix, check if key matches and strip prefix
828
- if mounted_server.prefix:
829
- if tool_key.startswith(f"{mounted_server.prefix}_"):
830
- tool_key = tool_key.removeprefix(f"{mounted_server.prefix}_")
831
- else:
832
- continue
833
- return await mounted_server.server._middleware_call_tool(
834
- tool_key, arguments
835
- )
836
- except NotFoundError:
837
- # Tool not found on this server, try the next one
838
- continue
839
-
840
- raise NotFoundError(f"Unknown tool: {key!r}")
841
-
842
  async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
843
  """
844
  Handle MCP 'readResource' requests.
@@ -1853,11 +1787,12 @@ class FastMCP(Generic[LifespanResultT]):
1853
  if as_proxy and not isinstance(server, FastMCPProxy):
1854
  server = FastMCPProxy(Client(transport=FastMCPTransport(server)))
1855
 
1856
- mounted_server = MountedServer(
1857
- server=server,
1858
- prefix=prefix,
1859
- )
1860
- self._mounted_servers.append(mounted_server)
 
1861
  self._cache.clear()
1862
 
1863
  async def import_server(
 
434
  logger.debug("Handler called: list_tools")
435
 
436
  with fastmcp.server.context.Context(fastmcp=self):
437
+ tools = await self._list_tools()
438
  return [tool.to_mcp_tool(name=tool.key) for tool in tools]
439
 
440
+ async def _list_tools(self) -> list[Tool]:
441
  """
442
  List all available tools, in the format expected by the low-level MCP
443
  server.
 
447
  async def _handler(
448
  context: MiddlewareContext[mcp.types.ListToolsRequest],
449
  ) -> list[Tool]:
450
+ tools = await self._tool_manager.list_tools()
451
 
452
  mcp_tools: list[Tool] = []
453
  for tool in tools:
 
469
  # Apply the middleware chain.
470
  return await self._apply_middleware(mw_context, _handler)
471
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  async def _mcp_list_resources(self) -> list[MCPResource]:
473
  logger.debug("Handler called: list_resources")
474
 
 
547
  f"Failed to get resources from mounted server '{mounted_server.prefix}': {e}"
548
  )
549
  continue
550
+ (
551
+ local_resources,
552
+ _,
553
+ ) = await self._resource_manager.get_resources_and_templates()
554
+ resources.update(local_resources)
555
  self._cache.set("resources", resources)
556
  return list(resources.values())
557
 
 
639
  f"'{mounted_server.prefix}': {e}"
640
  )
641
  continue
642
+ (
643
+ _,
644
+ local_templates,
645
+ ) = await self._resource_manager.get_resources_and_templates()
646
+ templates.update(local_templates)
647
  self._cache.set("resource_templates", templates)
648
  return list(templates.values())
649
 
 
719
  f"Failed to get prompts from mounted server '{mounted_server.prefix}': {e}"
720
  )
721
  continue
722
+ prompts.update(await self._prompt_manager.get_prompts())
723
  self._cache.set("prompts", prompts)
724
  return list(prompts.values())
725
 
 
742
 
743
  with fastmcp.server.context.Context(fastmcp=self):
744
  try:
745
+ return await self._call_tool(key, arguments)
746
  except DisabledError:
747
  raise NotFoundError(f"Unknown tool: {key}")
748
  except NotFoundError:
749
  raise NotFoundError(f"Unknown tool: {key}")
750
 
751
+ async def _call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
 
 
 
 
752
  """
753
+ Applies this server's middleware and delegates the filtered call to the manager.
754
  """
755
 
756
  async def _handler(
757
  context: MiddlewareContext[mcp.types.CallToolRequestParams],
758
  ) -> list[MCPContent]:
759
+ tool = await self._tool_manager.get_tool(context.message.name)
760
+ if not self._should_enable_component(tool):
761
+ raise NotFoundError(f"Unknown tool: {context.message.name!r}")
762
+
763
+ return await self._tool_manager.call_tool(
764
+ key=context.message.name, arguments=context.message.arguments or {}
765
  )
766
 
767
  mw_context = MiddlewareContext(
 
773
  )
774
  return await self._apply_middleware(mw_context, _handler)
775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
776
  async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
777
  """
778
  Handle MCP 'readResource' requests.
 
1787
  if as_proxy and not isinstance(server, FastMCPProxy):
1788
  server = FastMCPProxy(Client(transport=FastMCPTransport(server)))
1789
 
1790
+ # Delegate mounting to all three managers
1791
+ mounted_server = MountedServer(prefix=prefix, server=server)
1792
+ self._tool_manager.mount(mounted_server)
1793
+ self._resource_manager.mount(mounted_server)
1794
+ self._prompt_manager.mount(mounted_server)
1795
+
1796
  self._cache.clear()
1797
 
1798
  async def import_server(
src/fastmcp/tools/tool_manager.py CHANGED
@@ -1,8 +1,8 @@
1
- from __future__ import annotations as _annotations
2
 
3
  import warnings
4
  from collections.abc import Callable
5
- from typing import TYPE_CHECKING, Any
6
 
7
  from mcp.types import ToolAnnotations
8
 
@@ -14,7 +14,7 @@ from fastmcp.utilities.logging import get_logger
14
  from fastmcp.utilities.types import MCPContent
15
 
16
  if TYPE_CHECKING:
17
- pass
18
 
19
  logger = get_logger(__name__)
20
 
@@ -28,6 +28,7 @@ class ToolManager:
28
  mask_error_details: bool | None = None,
29
  ):
30
  self._tools: dict[str, Tool] = {}
 
31
  self.mask_error_details = mask_error_details or settings.mask_error_details
32
 
33
  # Default to "warn" if None is provided
@@ -42,23 +43,78 @@ class ToolManager:
42
 
43
  self.duplicate_behavior = duplicate_behavior
44
 
45
- def has_tool(self, key: str) -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  """Check if a tool exists."""
47
- return key in self._tools
 
48
 
49
- def get_tool(self, key: str) -> Tool:
50
  """Get tool by key."""
51
- if key in self._tools:
52
- return self._tools[key]
53
- raise NotFoundError(f"Unknown tool: {key}")
 
54
 
55
- def get_tools(self) -> dict[str, Tool]:
56
- """Get all registered tools, indexed by registered key."""
57
- return self._tools
 
 
58
 
59
- def list_tools(self) -> list[Tool]:
60
- """List all registered tools."""
61
- return list(self.get_tools().values())
 
 
 
62
 
63
  def add_tool_from_fn(
64
  self,
@@ -119,28 +175,44 @@ class ToolManager:
119
  if key in self._tools:
120
  del self._tools[key]
121
  else:
122
- raise NotFoundError(f"Unknown tool: {key}")
123
 
124
  async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
125
- """Call a tool by name with arguments."""
126
- tool = self.get_tool(key)
127
- if not tool:
128
- raise NotFoundError(f"Unknown tool: {key}")
129
-
130
- try:
131
- return await tool.run(arguments)
132
-
133
- # raise ToolErrors as-is
134
- except ToolError as e:
135
- logger.exception(f"Error calling tool {key!r}: {e}")
136
- raise e
137
-
138
- # Handle other exceptions
139
- except Exception as e:
140
- logger.exception(f"Error calling tool {key!r}: {e}")
141
- if self.mask_error_details:
142
- # Mask internal details
143
- raise ToolError(f"Error calling tool {key!r}") from e
144
- else:
145
- # Include original error details
146
- raise ToolError(f"Error calling tool {key!r}: {e}") from e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
 
3
  import warnings
4
  from collections.abc import Callable
5
+ from typing import TYPE_CHECKING, Any, Literal
6
 
7
  from mcp.types import ToolAnnotations
8
 
 
14
  from fastmcp.utilities.types import MCPContent
15
 
16
  if TYPE_CHECKING:
17
+ from fastmcp.server.server import MountedServer
18
 
19
  logger = get_logger(__name__)
20
 
 
28
  mask_error_details: bool | None = None,
29
  ):
30
  self._tools: dict[str, Tool] = {}
31
+ self._mounted_sources: list[MountedServer] = []
32
  self.mask_error_details = mask_error_details or settings.mask_error_details
33
 
34
  # Default to "warn" if None is provided
 
43
 
44
  self.duplicate_behavior = duplicate_behavior
45
 
46
+ def mount(self, server: MountedServer) -> None:
47
+ """Adds a mounted server as a source for tools."""
48
+ self._mounted_sources.append(server)
49
+
50
+ async def _load_tools(
51
+ self, *, mode: Literal["inventory", "protocol"]
52
+ ) -> dict[str, Tool]:
53
+ """
54
+ The single, consolidated recursive method for fetching tools. The 'mode'
55
+ parameter determines the communication path.
56
+
57
+ - mode="inventory": Manager-to-manager path for complete, unfiltered inventory
58
+ - mode="protocol": Server-to-server path for filtered MCP requests
59
+ """
60
+ all_tools: dict[str, Tool] = {}
61
+
62
+ for mounted in self._mounted_sources:
63
+ try:
64
+ if mode == "protocol":
65
+ # PATH 2: Use the server-to-server filtered path
66
+ child_results = await mounted.server._list_tools()
67
+ else: # mode == "inventory"
68
+ # PATH 1: Use the manager-to-manager unfiltered path
69
+ child_results = await mounted.server._tool_manager.get_tools()
70
+
71
+ # The combination logic is the same for both paths
72
+ child_dict = (
73
+ {t.key: t for t in child_results}
74
+ if isinstance(child_results, list)
75
+ else child_results
76
+ )
77
+ if mounted.prefix:
78
+ for tool in child_dict.values():
79
+ prefixed_tool = tool.with_key(f"{mounted.prefix}_{tool.key}")
80
+ all_tools[prefixed_tool.key] = prefixed_tool
81
+ else:
82
+ all_tools.update(child_dict)
83
+ except Exception as e:
84
+ # Skip failed mounts silently, matches existing behavior
85
+ logger.warning(
86
+ f"Failed to get tools from mounted server '{mounted.prefix}': {e}"
87
+ )
88
+ continue
89
+
90
+ # Finally, add local tools, which always take precedence
91
+ all_tools.update(self._tools)
92
+ return all_tools
93
+
94
+ async def has_tool(self, key: str) -> bool:
95
  """Check if a tool exists."""
96
+ tools = await self.get_tools()
97
+ return key in tools
98
 
99
+ async def get_tool(self, key: str) -> Tool:
100
  """Get tool by key."""
101
+ tools = await self.get_tools()
102
+ if key in tools:
103
+ return tools[key]
104
+ raise NotFoundError(f"Tool {key!r} not found")
105
 
106
+ async def get_tools(self) -> dict[str, Tool]:
107
+ """
108
+ Gets the complete, unfiltered inventory of all tools.
109
+ """
110
+ return await self._load_tools(mode="inventory")
111
 
112
+ async def list_tools(self) -> list[Tool]:
113
+ """
114
+ Lists all tools, applying protocol filtering.
115
+ """
116
+ tools_dict = await self._load_tools(mode="protocol")
117
+ return list(tools_dict.values())
118
 
119
  def add_tool_from_fn(
120
  self,
 
175
  if key in self._tools:
176
  del self._tools[key]
177
  else:
178
+ raise NotFoundError(f"Tool {key!r} not found")
179
 
180
  async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
181
+ """
182
+ Internal API for servers: Finds and calls a tool, respecting the
183
+ filtered protocol path.
184
+ """
185
+ # 1. Check local tools first. The server will have already applied its filter.
186
+ if key in self._tools:
187
+ tool = await self.get_tool(key)
188
+ if not tool:
189
+ raise NotFoundError(f"Tool {key!r} not found")
190
+
191
+ try:
192
+ return await tool.run(arguments)
193
+
194
+ # raise ToolErrors as-is
195
+ except ToolError as e:
196
+ logger.exception(f"Error calling tool {key!r}: {e}")
197
+ raise e
198
+
199
+ # Handle other exceptions
200
+ except Exception as e:
201
+ logger.exception(f"Error calling tool {key!r}: {e}")
202
+ if self.mask_error_details:
203
+ # Mask internal details
204
+ raise ToolError(f"Error calling tool {key!r}") from e
205
+ else:
206
+ # Include original error details
207
+ raise ToolError(f"Error calling tool {key!r}: {e}") from e
208
+
209
+ # 2. Check mounted servers using the filtered protocol path.
210
+ for mounted in reversed(self._mounted_sources):
211
+ if mounted.prefix and key.startswith(f"{mounted.prefix}_"):
212
+ key_on_child = key.removeprefix(f"{mounted.prefix}_")
213
+ try:
214
+ return await mounted.server._call_tool(key_on_child, arguments)
215
+ except NotFoundError:
216
+ continue
217
+
218
+ raise NotFoundError(f"Tool {key!r} not found.")
tests/tools/test_tool_manager.py CHANGED
@@ -17,7 +17,7 @@ from fastmcp.utilities.types import Image
17
 
18
 
19
  class TestAddTools:
20
- def test_basic_function(self):
21
  """Test registering and running a basic function."""
22
 
23
  def add(a: int, b: int) -> int:
@@ -28,7 +28,7 @@ class TestAddTools:
28
  tool = Tool.from_function(add)
29
  manager.add_tool(tool)
30
 
31
- tool = manager.get_tool("add")
32
  assert tool is not None
33
  assert tool.name == "add"
34
  assert tool.description == "Add two numbers."
@@ -46,13 +46,13 @@ class TestAddTools:
46
  tool = Tool.from_function(fetch_data)
47
  manager.add_tool(tool)
48
 
49
- tool = manager.get_tool("fetch_data")
50
  assert tool is not None
51
  assert tool.name == "fetch_data"
52
  assert tool.description == "Fetch data from URL."
53
  assert tool.parameters["properties"]["url"]["type"] == "string"
54
 
55
- def test_pydantic_model_function(self):
56
  """Test registering a function that takes a Pydantic model."""
57
 
58
  class UserInput(BaseModel):
@@ -67,7 +67,7 @@ class TestAddTools:
67
  tool = Tool.from_function(create_user)
68
  manager.add_tool(tool)
69
 
70
- tool = manager.get_tool("create_user")
71
  assert tool is not None
72
  assert tool.name == "create_user"
73
  assert tool.description == "Create a new user."
@@ -75,7 +75,7 @@ class TestAddTools:
75
  assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
76
  assert "flag" in tool.parameters["properties"]
77
 
78
- def test_callable_object(self):
79
  class Adder:
80
  """Adds two numbers."""
81
 
@@ -87,7 +87,7 @@ class TestAddTools:
87
  tool = Tool.from_function(Adder())
88
  manager.add_tool(tool)
89
 
90
- tool = manager.get_tool("Adder")
91
  assert tool is not None
92
  assert tool.name == "Adder"
93
  assert tool.description == "Adds two numbers."
@@ -95,7 +95,7 @@ class TestAddTools:
95
  assert tool.parameters["properties"]["x"]["type"] == "integer"
96
  assert tool.parameters["properties"]["y"]["type"] == "integer"
97
 
98
- def test_async_callable_object(self):
99
  class Adder:
100
  """Adds two numbers."""
101
 
@@ -107,7 +107,7 @@ class TestAddTools:
107
  tool = Tool.from_function(Adder())
108
  manager.add_tool(tool)
109
 
110
- tool = manager.get_tool("Adder")
111
  assert tool is not None
112
  assert tool.name == "Adder"
113
  assert tool.description == "Adds two numbers."
@@ -123,7 +123,7 @@ class TestAddTools:
123
  tool = Tool.from_function(image_tool)
124
  manager.add_tool(tool)
125
 
126
- tool = manager.get_tool("image_tool")
127
  result = await tool.run({"data": "test.png"})
128
  assert tool.parameters["properties"]["data"]["type"] == "string"
129
  assert isinstance(result[0], ImageContent)
@@ -148,7 +148,7 @@ class TestAddTools:
148
  tool = Tool.from_function(lambda x: x)
149
  manager.add_tool(tool)
150
 
151
- def test_remove_tool_successfully(self):
152
  """Test removing an added tool by key."""
153
  manager = ToolManager()
154
 
@@ -157,19 +157,19 @@ class TestAddTools:
157
 
158
  tool = Tool.from_function(add)
159
  manager.add_tool(tool)
160
- assert manager.get_tool("add") is not None
161
 
162
  manager.remove_tool("add")
163
  with pytest.raises(NotFoundError):
164
- manager.get_tool("add")
165
 
166
  def test_remove_tool_missing_key(self):
167
  """Test removing a tool that does not exist raises NotFoundError."""
168
  manager = ToolManager()
169
- with pytest.raises(NotFoundError, match=f"Unknown tool: {'missing'}"):
170
  manager.remove_tool("missing")
171
 
172
- def test_warn_on_duplicate_tools(self, caplog):
173
  """Test warning on duplicate tools."""
174
  manager = ToolManager(duplicate_behavior="warn")
175
 
@@ -183,7 +183,7 @@ class TestAddTools:
183
 
184
  assert "Tool already exists: test_tool" in caplog.text
185
  # Should have the tool
186
- assert manager.get_tool("test_tool") is not None
187
 
188
  def test_disable_warn_on_duplicate_tools(self, caplog):
189
  """Test disabling warning on duplicate tools."""
@@ -213,7 +213,7 @@ class TestAddTools:
213
  tool2 = Tool.from_function(test_fn, name="test_tool")
214
  manager.add_tool(tool2)
215
 
216
- def test_replace_duplicate_tools(self):
217
  """Test replacing duplicate tools."""
218
  manager = ToolManager(duplicate_behavior="replace")
219
 
@@ -229,12 +229,12 @@ class TestAddTools:
229
  manager.add_tool(result)
230
 
231
  # Should have replaced with the new tool
232
- tool = manager.get_tool("test_tool")
233
  assert tool is not None
234
  assert isinstance(tool, FunctionTool)
235
  assert tool.fn.__name__ == "replacement_fn"
236
 
237
- def test_ignore_duplicate_tools(self):
238
  """Test ignoring duplicate tools."""
239
  manager = ToolManager(duplicate_behavior="ignore")
240
 
@@ -250,7 +250,7 @@ class TestAddTools:
250
  manager.add_tool(result)
251
 
252
  # Should keep the original
253
- tool = manager.get_tool("test_tool")
254
  assert tool is not None
255
  assert isinstance(tool, FunctionTool)
256
  assert tool.fn.__name__ == "original_fn"
@@ -262,7 +262,7 @@ class TestAddTools:
262
  class TestToolTags:
263
  """Test functionality related to tool tags."""
264
 
265
- def test_add_tool_with_tags(self):
266
  """Test adding tags to a tool."""
267
 
268
  def example_tool(x: int) -> int:
@@ -274,11 +274,11 @@ class TestToolTags:
274
  manager.add_tool(tool)
275
 
276
  assert tool.tags == {"math", "utility"}
277
- tool = manager.get_tool("example_tool")
278
  assert tool is not None
279
  assert tool.tags == {"math", "utility"}
280
 
281
- def test_add_tool_with_empty_tags(self):
282
  """Test adding a tool with empty tags set."""
283
 
284
  def example_tool(x: int) -> int:
@@ -291,7 +291,7 @@ class TestToolTags:
291
 
292
  assert tool.tags == set()
293
 
294
- def test_add_tool_with_none_tags(self):
295
  """Test adding a tool with None tags."""
296
 
297
  def example_tool(x: int) -> int:
@@ -304,7 +304,7 @@ class TestToolTags:
304
 
305
  assert tool.tags == set()
306
 
307
- def test_list_tools_with_tags(self):
308
  """Test listing tools with specific tags."""
309
 
310
  def math_tool(x: int) -> int:
@@ -328,12 +328,16 @@ class TestToolTags:
328
  manager.add_tool(tool3)
329
 
330
  # Check if we can filter by tags when listing tools
331
- math_tools = [tool for tool in manager.list_tools() if "math" in tool.tags]
 
 
332
  assert len(math_tools) == 2
333
  assert {tool.name for tool in math_tools} == {"math_tool", "mixed_tool"}
334
 
335
  utility_tools = [
336
- tool for tool in manager.list_tools() if "utility" in tool.tags
 
 
337
  ]
338
  assert len(utility_tools) == 2
339
  assert {tool.name for tool in utility_tools} == {"string_tool", "mixed_tool"}
@@ -416,7 +420,7 @@ class TestCallTools:
416
 
417
  async def test_call_unknown_tool(self):
418
  manager = ToolManager()
419
- with pytest.raises(NotFoundError, match="Unknown tool: unknown"):
420
  await manager.call_tool("unknown", {"a": 1})
421
 
422
  async def test_call_tool_with_list_int_input(self):
@@ -728,7 +732,7 @@ class TestContextHandling:
728
  class TestCustomToolNames:
729
  """Test adding tools with custom names that differ from their function names."""
730
 
731
- def test_add_tool_with_custom_name(self):
732
  """Test adding a tool with a custom name parameter using add_tool_from_fn."""
733
 
734
  def original_fn(x: int) -> int:
@@ -739,15 +743,15 @@ class TestCustomToolNames:
739
  manager.add_tool(tool)
740
 
741
  # The tool is stored under the custom name and its .name is also set to custom_name
742
- assert manager.get_tool("custom_name") is not None
743
  assert tool.name == "custom_name"
744
  assert isinstance(tool, FunctionTool)
745
  assert tool.fn.__name__ == "original_fn"
746
  # The tool should not be accessible via its original function name
747
- with pytest.raises(NotFoundError, match="Unknown tool: original_fn"):
748
- manager.get_tool("original_fn")
749
 
750
- def test_add_tool_object_with_custom_key(self):
751
  """Test adding a Tool object with a custom key using add_tool()."""
752
 
753
  def fn(x: int) -> int:
@@ -759,13 +763,13 @@ class TestCustomToolNames:
759
  # Store it under a different name
760
  manager.add_tool(tool, key="proxy_tool")
761
  # The tool is accessible under the key
762
- stored = manager.get_tool("proxy_tool")
763
  assert stored is not None
764
  # But the tool's .name is unchanged
765
  assert stored.name == "my_tool"
766
  # The tool is not accessible under its original name
767
- with pytest.raises(NotFoundError, match="Unknown tool: my_tool"):
768
- manager.get_tool("my_tool")
769
 
770
  async def test_call_tool_with_custom_name(self):
771
  """Test calling a tool added with a custom name."""
@@ -783,10 +787,10 @@ class TestCustomToolNames:
783
  assert result[0].text == "15" # type: ignore[attr-defined]
784
 
785
  # Original name should not be registered
786
- with pytest.raises(NotFoundError, match="Unknown tool: multiply"):
787
  await manager.call_tool("multiply", {"a": 5, "b": 3})
788
 
789
- def test_replace_tool_keeps_original_name(self):
790
  """Test that replacing a tool with "replace" keeps the original name."""
791
 
792
  def original_fn(x: int) -> int:
@@ -808,7 +812,7 @@ class TestCustomToolNames:
808
  manager.add_tool(replacement_tool)
809
 
810
  # The tool object should have been replaced
811
- stored_tool = manager.get_tool("test_tool")
812
  assert stored_tool is not None
813
  assert stored_tool == replacement_tool
814
 
 
17
 
18
 
19
  class TestAddTools:
20
+ async def test_basic_function(self):
21
  """Test registering and running a basic function."""
22
 
23
  def add(a: int, b: int) -> int:
 
28
  tool = Tool.from_function(add)
29
  manager.add_tool(tool)
30
 
31
+ tool = await manager.get_tool("add")
32
  assert tool is not None
33
  assert tool.name == "add"
34
  assert tool.description == "Add two numbers."
 
46
  tool = Tool.from_function(fetch_data)
47
  manager.add_tool(tool)
48
 
49
+ tool = await manager.get_tool("fetch_data")
50
  assert tool is not None
51
  assert tool.name == "fetch_data"
52
  assert tool.description == "Fetch data from URL."
53
  assert tool.parameters["properties"]["url"]["type"] == "string"
54
 
55
+ async def test_pydantic_model_function(self):
56
  """Test registering a function that takes a Pydantic model."""
57
 
58
  class UserInput(BaseModel):
 
67
  tool = Tool.from_function(create_user)
68
  manager.add_tool(tool)
69
 
70
+ tool = await manager.get_tool("create_user")
71
  assert tool is not None
72
  assert tool.name == "create_user"
73
  assert tool.description == "Create a new user."
 
75
  assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
76
  assert "flag" in tool.parameters["properties"]
77
 
78
+ async def test_callable_object(self):
79
  class Adder:
80
  """Adds two numbers."""
81
 
 
87
  tool = Tool.from_function(Adder())
88
  manager.add_tool(tool)
89
 
90
+ tool = await manager.get_tool("Adder")
91
  assert tool is not None
92
  assert tool.name == "Adder"
93
  assert tool.description == "Adds two numbers."
 
95
  assert tool.parameters["properties"]["x"]["type"] == "integer"
96
  assert tool.parameters["properties"]["y"]["type"] == "integer"
97
 
98
+ async def test_async_callable_object(self):
99
  class Adder:
100
  """Adds two numbers."""
101
 
 
107
  tool = Tool.from_function(Adder())
108
  manager.add_tool(tool)
109
 
110
+ tool = await manager.get_tool("Adder")
111
  assert tool is not None
112
  assert tool.name == "Adder"
113
  assert tool.description == "Adds two numbers."
 
123
  tool = Tool.from_function(image_tool)
124
  manager.add_tool(tool)
125
 
126
+ tool = await manager.get_tool("image_tool")
127
  result = await tool.run({"data": "test.png"})
128
  assert tool.parameters["properties"]["data"]["type"] == "string"
129
  assert isinstance(result[0], ImageContent)
 
148
  tool = Tool.from_function(lambda x: x)
149
  manager.add_tool(tool)
150
 
151
+ async def test_remove_tool_successfully(self):
152
  """Test removing an added tool by key."""
153
  manager = ToolManager()
154
 
 
157
 
158
  tool = Tool.from_function(add)
159
  manager.add_tool(tool)
160
+ assert await manager.get_tool("add") is not None
161
 
162
  manager.remove_tool("add")
163
  with pytest.raises(NotFoundError):
164
+ await manager.get_tool("add")
165
 
166
  def test_remove_tool_missing_key(self):
167
  """Test removing a tool that does not exist raises NotFoundError."""
168
  manager = ToolManager()
169
+ with pytest.raises(NotFoundError, match="Tool 'missing' not found"):
170
  manager.remove_tool("missing")
171
 
172
+ async def test_warn_on_duplicate_tools(self, caplog):
173
  """Test warning on duplicate tools."""
174
  manager = ToolManager(duplicate_behavior="warn")
175
 
 
183
 
184
  assert "Tool already exists: test_tool" in caplog.text
185
  # Should have the tool
186
+ assert await manager.get_tool("test_tool") is not None
187
 
188
  def test_disable_warn_on_duplicate_tools(self, caplog):
189
  """Test disabling warning on duplicate tools."""
 
213
  tool2 = Tool.from_function(test_fn, name="test_tool")
214
  manager.add_tool(tool2)
215
 
216
+ async def test_replace_duplicate_tools(self):
217
  """Test replacing duplicate tools."""
218
  manager = ToolManager(duplicate_behavior="replace")
219
 
 
229
  manager.add_tool(result)
230
 
231
  # Should have replaced with the new tool
232
+ tool = await manager.get_tool("test_tool")
233
  assert tool is not None
234
  assert isinstance(tool, FunctionTool)
235
  assert tool.fn.__name__ == "replacement_fn"
236
 
237
+ async def test_ignore_duplicate_tools(self):
238
  """Test ignoring duplicate tools."""
239
  manager = ToolManager(duplicate_behavior="ignore")
240
 
 
250
  manager.add_tool(result)
251
 
252
  # Should keep the original
253
+ tool = await manager.get_tool("test_tool")
254
  assert tool is not None
255
  assert isinstance(tool, FunctionTool)
256
  assert tool.fn.__name__ == "original_fn"
 
262
  class TestToolTags:
263
  """Test functionality related to tool tags."""
264
 
265
+ async def test_add_tool_with_tags(self):
266
  """Test adding tags to a tool."""
267
 
268
  def example_tool(x: int) -> int:
 
274
  manager.add_tool(tool)
275
 
276
  assert tool.tags == {"math", "utility"}
277
+ tool = await manager.get_tool("example_tool")
278
  assert tool is not None
279
  assert tool.tags == {"math", "utility"}
280
 
281
+ async def test_add_tool_with_empty_tags(self):
282
  """Test adding a tool with empty tags set."""
283
 
284
  def example_tool(x: int) -> int:
 
291
 
292
  assert tool.tags == set()
293
 
294
+ async def test_add_tool_with_none_tags(self):
295
  """Test adding a tool with None tags."""
296
 
297
  def example_tool(x: int) -> int:
 
304
 
305
  assert tool.tags == set()
306
 
307
+ async def test_list_tools_with_tags(self):
308
  """Test listing tools with specific tags."""
309
 
310
  def math_tool(x: int) -> int:
 
328
  manager.add_tool(tool3)
329
 
330
  # Check if we can filter by tags when listing tools
331
+ math_tools = [
332
+ tool for tool in (await manager.get_tools()).values() if "math" in tool.tags
333
+ ]
334
  assert len(math_tools) == 2
335
  assert {tool.name for tool in math_tools} == {"math_tool", "mixed_tool"}
336
 
337
  utility_tools = [
338
+ tool
339
+ for tool in (await manager.get_tools()).values()
340
+ if "utility" in tool.tags
341
  ]
342
  assert len(utility_tools) == 2
343
  assert {tool.name for tool in utility_tools} == {"string_tool", "mixed_tool"}
 
420
 
421
  async def test_call_unknown_tool(self):
422
  manager = ToolManager()
423
+ with pytest.raises(NotFoundError, match="Tool 'unknown' not found"):
424
  await manager.call_tool("unknown", {"a": 1})
425
 
426
  async def test_call_tool_with_list_int_input(self):
 
732
  class TestCustomToolNames:
733
  """Test adding tools with custom names that differ from their function names."""
734
 
735
+ async def test_add_tool_with_custom_name(self):
736
  """Test adding a tool with a custom name parameter using add_tool_from_fn."""
737
 
738
  def original_fn(x: int) -> int:
 
743
  manager.add_tool(tool)
744
 
745
  # The tool is stored under the custom name and its .name is also set to custom_name
746
+ assert await manager.get_tool("custom_name") is not None
747
  assert tool.name == "custom_name"
748
  assert isinstance(tool, FunctionTool)
749
  assert tool.fn.__name__ == "original_fn"
750
  # The tool should not be accessible via its original function name
751
+ with pytest.raises(NotFoundError, match="Tool 'original_fn' not found"):
752
+ await manager.get_tool("original_fn")
753
 
754
+ async def test_add_tool_object_with_custom_key(self):
755
  """Test adding a Tool object with a custom key using add_tool()."""
756
 
757
  def fn(x: int) -> int:
 
763
  # Store it under a different name
764
  manager.add_tool(tool, key="proxy_tool")
765
  # The tool is accessible under the key
766
+ stored = await manager.get_tool("proxy_tool")
767
  assert stored is not None
768
  # But the tool's .name is unchanged
769
  assert stored.name == "my_tool"
770
  # The tool is not accessible under its original name
771
+ with pytest.raises(NotFoundError, match="Tool 'my_tool' not found"):
772
+ await manager.get_tool("my_tool")
773
 
774
  async def test_call_tool_with_custom_name(self):
775
  """Test calling a tool added with a custom name."""
 
787
  assert result[0].text == "15" # type: ignore[attr-defined]
788
 
789
  # Original name should not be registered
790
+ with pytest.raises(NotFoundError, match="Tool 'multiply' not found"):
791
  await manager.call_tool("multiply", {"a": 5, "b": 3})
792
 
793
+ async def test_replace_tool_keeps_original_name(self):
794
  """Test that replacing a tool with "replace" keeps the original name."""
795
 
796
  def original_fn(x: int) -> int:
 
812
  manager.add_tool(replacement_tool)
813
 
814
  # The tool object should have been replaced
815
+ stored_tool = await manager.get_tool("test_tool")
816
  assert stored_tool is not None
817
  assert stored_tool == replacement_tool
818