Jeremiah Lowin commited on
Commit
72c9ff1
·
1 Parent(s): 21a2649

Allow tool manager to store an alternative name for tools

Browse files
src/fastmcp/server/server.py CHANGED
@@ -195,17 +195,7 @@ class FastMCP(Generic[LifespanResultT]):
195
 
196
  See `list_tools` for a more ergonomic way to list tools.
197
  """
198
-
199
- tools = self.list_tools()
200
-
201
- return [
202
- MCPTool(
203
- name=info.name,
204
- description=info.description,
205
- inputSchema=info.parameters,
206
- )
207
- for info in tools
208
- ]
209
 
210
  def get_context(self) -> "Context[ServerSession, LifespanResultT]":
211
  """
 
195
 
196
  See `list_tools` for a more ergonomic way to list tools.
197
  """
198
+ return self._tool_manager.list_mcp_tools()
 
 
 
 
 
 
 
 
 
 
199
 
200
  def get_context(self) -> "Context[ServerSession, LifespanResultT]":
201
  """
src/fastmcp/tools/tool.py CHANGED
@@ -4,6 +4,7 @@ import inspect
4
  from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Annotated, Any
6
 
 
7
  from pydantic import BaseModel, BeforeValidator, Field
8
 
9
  from fastmcp.exceptions import ToolError
@@ -101,6 +102,14 @@ class Tool(BaseModel):
101
  except Exception as e:
102
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
103
 
 
 
 
 
 
 
 
 
104
  def __eq__(self, other: object) -> bool:
105
  if not isinstance(other, Tool):
106
  return False
 
4
  from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Annotated, Any
6
 
7
+ from mcp.types import Tool as MCPTool
8
  from pydantic import BaseModel, BeforeValidator, Field
9
 
10
  from fastmcp.exceptions import ToolError
 
102
  except Exception as e:
103
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
104
 
105
+ def to_mcp_tool(self, **overrides: Any) -> MCPTool:
106
+ kwargs = {
107
+ "name": self.name,
108
+ "description": self.description,
109
+ "inputSchema": self.parameters,
110
+ }
111
+ return MCPTool(**kwargs | overrides)
112
+
113
  def __eq__(self, other: object) -> bool:
114
  if not isinstance(other, Tool):
115
  return False
src/fastmcp/tools/tool_manager.py CHANGED
@@ -1,6 +1,5 @@
1
  from __future__ import annotations as _annotations
2
 
3
- import copy
4
  from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Any
6
 
@@ -8,7 +7,7 @@ from mcp.shared.context import LifespanContextT
8
 
9
  from fastmcp.exceptions import ToolError
10
  from fastmcp.settings import DuplicateBehavior
11
- from fastmcp.tools.tool import Tool
12
  from fastmcp.utilities.logging import get_logger
13
 
14
  if TYPE_CHECKING:
@@ -34,6 +33,10 @@ class ToolManager:
34
  """List all registered tools."""
35
  return list(self._tools.values())
36
 
 
 
 
 
37
  def add_tool_from_fn(
38
  self,
39
  fn: Callable[..., Any],
@@ -45,20 +48,21 @@ class ToolManager:
45
  tool = Tool.from_function(fn, name=name, description=description, tags=tags)
46
  return self.add_tool(tool)
47
 
48
- def add_tool(self, tool: Tool) -> Tool:
49
  """Register a tool with the server."""
50
- existing = self._tools.get(tool.name)
 
51
  if existing:
52
  if self.duplicate_behavior == DuplicateBehavior.WARN:
53
- logger.warning(f"Tool already exists: {tool.name}")
54
- self._tools[tool.name] = tool
55
  elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
56
- self._tools[tool.name] = tool
57
  elif self.duplicate_behavior == DuplicateBehavior.ERROR:
58
- raise ValueError(f"Tool already exists: {tool.name}")
59
  elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
60
  pass
61
- self._tools[tool.name] = tool
62
  return tool
63
 
64
  async def call_tool(
@@ -90,10 +94,5 @@ class ToolManager:
90
  """
91
  for name, tool in tool_manager._tools.items():
92
  prefixed_name = f"{prefix}{name}" if prefix else name
93
-
94
- new_tool = copy.copy(tool)
95
- new_tool.name = prefixed_name
96
-
97
- # Store the copied tool
98
- self.add_tool(new_tool)
99
- logger.debug(f'Imported tool "{name}" as "{prefixed_name}"')
 
1
  from __future__ import annotations as _annotations
2
 
 
3
  from collections.abc import Callable
4
  from typing import TYPE_CHECKING, Any
5
 
 
7
 
8
  from fastmcp.exceptions import ToolError
9
  from fastmcp.settings import DuplicateBehavior
10
+ from fastmcp.tools.tool import MCPTool, Tool
11
  from fastmcp.utilities.logging import get_logger
12
 
13
  if TYPE_CHECKING:
 
33
  """List all registered tools."""
34
  return list(self._tools.values())
35
 
36
+ def list_mcp_tools(self) -> list[MCPTool]:
37
+ """List all registered tools in the format expected by the low-level MCP server."""
38
+ return [tool.to_mcp_tool(name=name) for name, tool in self._tools.items()]
39
+
40
  def add_tool_from_fn(
41
  self,
42
  fn: Callable[..., Any],
 
48
  tool = Tool.from_function(fn, name=name, description=description, tags=tags)
49
  return self.add_tool(tool)
50
 
51
+ def add_tool(self, tool: Tool, name: str | None = None) -> Tool:
52
  """Register a tool with the server."""
53
+ name = name or tool.name
54
+ existing = self._tools.get(name)
55
  if existing:
56
  if self.duplicate_behavior == DuplicateBehavior.WARN:
57
+ logger.warning(f"Tool already exists: {name}")
58
+ self._tools[name] = tool
59
  elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
60
+ self._tools[name] = tool
61
  elif self.duplicate_behavior == DuplicateBehavior.ERROR:
62
+ raise ValueError(f"Tool already exists: {name}")
63
  elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
64
  pass
65
+ self._tools[name] = tool
66
  return tool
67
 
68
  async def call_tool(
 
94
  """
95
  for name, tool in tool_manager._tools.items():
96
  prefixed_name = f"{prefix}{name}" if prefix else name
97
+ self.add_tool(tool, name=prefixed_name)
98
+ logger.debug(f'Imported tool "{tool.name}" as "{prefixed_name}"')
 
 
 
 
 
tests/server/test_mount.py CHANGED
@@ -24,8 +24,9 @@ async def test_mount_basic_functionality():
24
  assert "sub_tool" in sub_app._tool_manager._tools
25
 
26
  # Verify the original tool still exists in the sub-app
27
- tool = main_app._tool_manager._tools["sub_sub_tool"]
28
- assert tool.name == "sub_sub_tool"
 
29
  assert callable(tool.fn)
30
 
31
 
@@ -230,3 +231,78 @@ async def test_mount_lifespan():
230
  "exit SubApp",
231
  "exit MainApp",
232
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  assert "sub_tool" in sub_app._tool_manager._tools
25
 
26
  # Verify the original tool still exists in the sub-app
27
+ tool = main_app._tool_manager.get_tool("sub_sub_tool")
28
+ assert tool is not None
29
+ assert tool.name == "sub_tool"
30
  assert callable(tool.fn)
31
 
32
 
 
231
  "exit SubApp",
232
  "exit MainApp",
233
  ]
234
+
235
+
236
+ async def test_mount_with_proxy_tools():
237
+ """Test mounting with tools that have custom names (proxy tools)."""
238
+ # Create apps
239
+ main_app = FastMCP("MainApp")
240
+ api_app = FastMCP("APIApp")
241
+
242
+ # Create a tool function
243
+ def fetch_data(query: str) -> str:
244
+ return f"Data for query: {query}"
245
+
246
+ # Add the tool to the API app with a custom name
247
+ api_app.add_tool(fetch_data, name="get_data")
248
+
249
+ # Verify the tool is registered with the custom name in the source app
250
+ assert api_app._tool_manager.get_tool("get_data") is not None
251
+
252
+ # Mount the API app to the main app
253
+ main_app.mount("api", api_app)
254
+
255
+ # Verify the tool was imported with the prefixed custom name
256
+ tool = main_app._tool_manager.get_tool("api_get_data")
257
+ assert tool is not None
258
+
259
+ # The internal function name should be preserved
260
+ assert tool.fn.__name__ == "fetch_data"
261
+
262
+ # The tool should be callable through the mounted name
263
+ context = main_app.get_context()
264
+ result = await main_app._tool_manager.call_tool(
265
+ "api_get_data", {"query": "test"}, context=context
266
+ )
267
+ assert result == "Data for query: test"
268
+
269
+
270
+ async def test_mount_nested_prefixed_tools():
271
+ """Test mounting tools with multiple layers of prefixes."""
272
+ # Create apps
273
+ main_app = FastMCP("MainApp")
274
+ service_app = FastMCP("ServiceApp")
275
+ provider_app = FastMCP("ProviderApp")
276
+
277
+ # Create a tool function
278
+ def calculate_value(input: int) -> int:
279
+ return input * 2
280
+
281
+ # Add the tool to the provider app with a custom name
282
+ provider_app.add_tool(calculate_value, name="compute")
283
+
284
+ # The provider has a tool registered with a custom name
285
+ assert provider_app._tool_manager.get_tool("compute") is not None
286
+
287
+ # First mount: Mount the provider app to the service app
288
+ service_app.mount("provider", provider_app)
289
+
290
+ # Verify the tool is accessible in the service app with the first prefix
291
+ assert service_app._tool_manager.get_tool("provider_compute") is not None
292
+
293
+ # Second mount: Mount the service app to the main app
294
+ main_app.mount("service", service_app)
295
+
296
+ # Verify the tool is accessible in the main app with both prefixes
297
+ nested_tool = main_app._tool_manager.get_tool("service_provider_compute")
298
+ assert nested_tool is not None
299
+
300
+ # The internal function name should still be preserved after multiple mounts
301
+ assert nested_tool.fn.__name__ == "calculate_value"
302
+
303
+ # The tool should be callable through the fully-qualified name
304
+ context = main_app.get_context()
305
+ result = await main_app._tool_manager.call_tool(
306
+ "service_provider_compute", {"input": 21}, context=context
307
+ )
308
+ assert result == 42
tests/server/test_server.py CHANGED
@@ -204,6 +204,30 @@ class TestToolDecorator:
204
  assert len(tools) == 1
205
  assert tools[0].tags == {"example", "test-tag"}
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
  class TestResourceDecorator:
209
  async def test_no_resources_before_decorator(self):
 
204
  assert len(tools) == 1
205
  assert tools[0].tags == {"example", "test-tag"}
206
 
207
+ async def test_add_tool_with_custom_name(self):
208
+ """Test adding a tool with a custom name using server.add_tool()."""
209
+ mcp = FastMCP()
210
+
211
+ def multiply(a: int, b: int) -> int:
212
+ """Multiply two numbers."""
213
+ return a * b
214
+
215
+ # Add the tool with a custom name
216
+ mcp.add_tool(multiply, name="custom_multiply")
217
+
218
+ # Check that the tool is registered with the custom name
219
+ tools = mcp.list_tools()
220
+ tool_names = [t.name for t in tools]
221
+ assert "custom_multiply" in tool_names
222
+
223
+ # Call the tool by its custom name
224
+ result = await mcp.call_tool("custom_multiply", {"a": 5, "b": 3})
225
+ assert isinstance(result[0], TextContent)
226
+ assert result[0].text == "15"
227
+
228
+ # Original name should not be registered
229
+ assert "multiply" not in tool_names
230
+
231
 
232
  class TestResourceDecorator:
233
  async def test_no_resources_before_decorator(self):
tests/tools/test_tool_manager.py CHANGED
@@ -7,6 +7,7 @@ from pydantic import BaseModel
7
  from fastmcp.exceptions import ToolError
8
  from fastmcp.settings import DuplicateBehavior
9
  from fastmcp.tools import ToolManager
 
10
 
11
 
12
  class TestAddTools:
@@ -28,7 +29,6 @@ class TestAddTools:
28
  assert tool.parameters["properties"]["a"]["type"] == "integer"
29
  assert tool.parameters["properties"]["b"]["type"] == "integer"
30
 
31
- @pytest.mark.anyio
32
  async def test_async_function(self):
33
  """Test registering and running an async function."""
34
 
@@ -137,8 +137,15 @@ class TestAddTools:
137
 
138
  # Should have replaced the first tool with the second
139
  stored_tool = manager.get_tool("test_tool")
 
140
  assert stored_tool == replacement_tool
141
 
 
 
 
 
 
 
142
 
143
  class TestToolTags:
144
  """Test functionality related to tool tags."""
@@ -232,7 +239,6 @@ class TestToolTags:
232
 
233
 
234
  class TestCallTools:
235
- @pytest.mark.anyio
236
  async def test_call_tool(self):
237
  def add(a: int, b: int) -> int:
238
  """Add two numbers."""
@@ -243,7 +249,6 @@ class TestCallTools:
243
  result = await manager.call_tool("add", {"a": 1, "b": 2})
244
  assert result == 3
245
 
246
- @pytest.mark.anyio
247
  async def test_call_async_tool(self):
248
  async def double(n: int) -> int:
249
  """Double a number."""
@@ -254,7 +259,6 @@ class TestCallTools:
254
  result = await manager.call_tool("double", {"n": 5})
255
  assert result == 10
256
 
257
- @pytest.mark.anyio
258
  async def test_call_tool_with_default_args(self):
259
  def add(a: int, b: int = 1) -> int:
260
  """Add two numbers."""
@@ -265,7 +269,6 @@ class TestCallTools:
265
  result = await manager.call_tool("add", {"a": 1})
266
  assert result == 2
267
 
268
- @pytest.mark.anyio
269
  async def test_call_tool_with_missing_args(self):
270
  def add(a: int, b: int) -> int:
271
  """Add two numbers."""
@@ -276,13 +279,11 @@ class TestCallTools:
276
  with pytest.raises(ToolError):
277
  await manager.call_tool("add", {"a": 1})
278
 
279
- @pytest.mark.anyio
280
  async def test_call_unknown_tool(self):
281
  manager = ToolManager()
282
  with pytest.raises(ToolError):
283
  await manager.call_tool("unknown", {"a": 1})
284
 
285
- @pytest.mark.anyio
286
  async def test_call_tool_with_list_int_input(self):
287
  def sum_vals(vals: list[int]) -> int:
288
  return sum(vals)
@@ -295,7 +296,6 @@ class TestCallTools:
295
  result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
296
  assert result == 6
297
 
298
- @pytest.mark.anyio
299
  async def test_call_tool_with_list_str_or_str_input(self):
300
  def concat_strs(vals: list[str] | str) -> str:
301
  return vals if isinstance(vals, str) else "".join(vals)
@@ -312,7 +312,6 @@ class TestCallTools:
312
  result = await manager.call_tool("concat_strs", {"vals": '"a"'})
313
  assert result == '"a"'
314
 
315
- @pytest.mark.anyio
316
  async def test_call_tool_with_complex_model(self):
317
  from fastmcp import Context
318
 
@@ -341,7 +340,6 @@ class TestCallTools:
341
 
342
 
343
  class TestToolSchema:
344
- @pytest.mark.anyio
345
  async def test_context_arg_excluded_from_schema(self):
346
  from fastmcp import Context
347
 
@@ -376,7 +374,6 @@ class TestContextHandling:
376
  tool = manager.add_tool_from_fn(tool_without_context)
377
  assert tool.context_kwarg is None
378
 
379
- @pytest.mark.anyio
380
  async def test_context_injection(self):
381
  """Test that context is properly injected during tool execution."""
382
  from fastmcp import Context, FastMCP
@@ -393,7 +390,6 @@ class TestContextHandling:
393
  result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
394
  assert result == "42"
395
 
396
- @pytest.mark.anyio
397
  async def test_context_injection_async(self):
398
  """Test that context is properly injected in async tools."""
399
  from fastmcp import Context, FastMCP
@@ -410,7 +406,6 @@ class TestContextHandling:
410
  result = await manager.call_tool("async_tool", {"x": 42}, context=ctx)
411
  assert result == "42"
412
 
413
- @pytest.mark.anyio
414
  async def test_context_optional(self):
415
  """Test that context is optional when calling tools."""
416
  from fastmcp import Context
@@ -424,7 +419,6 @@ class TestContextHandling:
424
  result = await manager.call_tool("tool_with_context", {"x": 42})
425
  assert result == "42"
426
 
427
- @pytest.mark.anyio
428
  async def test_context_error_handling(self):
429
  """Test error handling when context injection fails."""
430
  from fastmcp import Context, FastMCP
@@ -545,3 +539,147 @@ class TestImportTools:
545
  assert (
546
  main_manager._tools["news/headlines"].fn.__name__ == headlines_fn.__name__
547
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from fastmcp.exceptions import ToolError
8
  from fastmcp.settings import DuplicateBehavior
9
  from fastmcp.tools import ToolManager
10
+ from fastmcp.tools.tool import Tool
11
 
12
 
13
  class TestAddTools:
 
29
  assert tool.parameters["properties"]["a"]["type"] == "integer"
30
  assert tool.parameters["properties"]["b"]["type"] == "integer"
31
 
 
32
  async def test_async_function(self):
33
  """Test registering and running an async function."""
34
 
 
137
 
138
  # Should have replaced the first tool with the second
139
  stored_tool = manager.get_tool("test_tool")
140
+ assert stored_tool is not None
141
  assert stored_tool == replacement_tool
142
 
143
+ # The name should still be the same
144
+ assert stored_tool.name == "test_tool"
145
+
146
+ # But the function is different
147
+ assert stored_tool.fn.__name__ == "replacement_fn"
148
+
149
 
150
  class TestToolTags:
151
  """Test functionality related to tool tags."""
 
239
 
240
 
241
  class TestCallTools:
 
242
  async def test_call_tool(self):
243
  def add(a: int, b: int) -> int:
244
  """Add two numbers."""
 
249
  result = await manager.call_tool("add", {"a": 1, "b": 2})
250
  assert result == 3
251
 
 
252
  async def test_call_async_tool(self):
253
  async def double(n: int) -> int:
254
  """Double a number."""
 
259
  result = await manager.call_tool("double", {"n": 5})
260
  assert result == 10
261
 
 
262
  async def test_call_tool_with_default_args(self):
263
  def add(a: int, b: int = 1) -> int:
264
  """Add two numbers."""
 
269
  result = await manager.call_tool("add", {"a": 1})
270
  assert result == 2
271
 
 
272
  async def test_call_tool_with_missing_args(self):
273
  def add(a: int, b: int) -> int:
274
  """Add two numbers."""
 
279
  with pytest.raises(ToolError):
280
  await manager.call_tool("add", {"a": 1})
281
 
 
282
  async def test_call_unknown_tool(self):
283
  manager = ToolManager()
284
  with pytest.raises(ToolError):
285
  await manager.call_tool("unknown", {"a": 1})
286
 
 
287
  async def test_call_tool_with_list_int_input(self):
288
  def sum_vals(vals: list[int]) -> int:
289
  return sum(vals)
 
296
  result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
297
  assert result == 6
298
 
 
299
  async def test_call_tool_with_list_str_or_str_input(self):
300
  def concat_strs(vals: list[str] | str) -> str:
301
  return vals if isinstance(vals, str) else "".join(vals)
 
312
  result = await manager.call_tool("concat_strs", {"vals": '"a"'})
313
  assert result == '"a"'
314
 
 
315
  async def test_call_tool_with_complex_model(self):
316
  from fastmcp import Context
317
 
 
340
 
341
 
342
  class TestToolSchema:
 
343
  async def test_context_arg_excluded_from_schema(self):
344
  from fastmcp import Context
345
 
 
374
  tool = manager.add_tool_from_fn(tool_without_context)
375
  assert tool.context_kwarg is None
376
 
 
377
  async def test_context_injection(self):
378
  """Test that context is properly injected during tool execution."""
379
  from fastmcp import Context, FastMCP
 
390
  result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
391
  assert result == "42"
392
 
 
393
  async def test_context_injection_async(self):
394
  """Test that context is properly injected in async tools."""
395
  from fastmcp import Context, FastMCP
 
406
  result = await manager.call_tool("async_tool", {"x": 42}, context=ctx)
407
  assert result == "42"
408
 
 
409
  async def test_context_optional(self):
410
  """Test that context is optional when calling tools."""
411
  from fastmcp import Context
 
419
  result = await manager.call_tool("tool_with_context", {"x": 42})
420
  assert result == "42"
421
 
 
422
  async def test_context_error_handling(self):
423
  """Test error handling when context injection fails."""
424
  from fastmcp import Context, FastMCP
 
539
  assert (
540
  main_manager._tools["news/headlines"].fn.__name__ == headlines_fn.__name__
541
  )
542
+
543
+
544
+ class TestCustomToolNames:
545
+ """Test adding tools with custom names that differ from their function names."""
546
+
547
+ def test_add_tool_with_custom_name(self):
548
+ """Test adding a tool with a custom name parameter using add_tool_from_fn."""
549
+
550
+ def original_fn(x: int) -> int:
551
+ return x * 2
552
+
553
+ manager = ToolManager()
554
+ tool = manager.add_tool_from_fn(original_fn, name="custom_name")
555
+
556
+ # The tool is stored under the custom name and its .name is also set to custom_name
557
+ assert manager.get_tool("custom_name") is not None
558
+ assert tool.name == "custom_name"
559
+ assert tool.fn.__name__ == "original_fn"
560
+ # The tool should not be accessible via its original function name
561
+ assert manager.get_tool("original_fn") is None
562
+
563
+ def test_add_tool_object_with_custom_storage_name(self):
564
+ """Test adding a Tool object with a custom storage name using add_tool()."""
565
+
566
+ def fn(x: int) -> int:
567
+ return x + 1
568
+
569
+ # Create a tool with a specific name
570
+ tool = Tool.from_function(fn, name="my_tool")
571
+ manager = ToolManager()
572
+ # Store it under a different name
573
+ manager.add_tool(tool, name="proxy_tool")
574
+ # The tool is accessible under the storage name
575
+ stored = manager.get_tool("proxy_tool")
576
+ assert stored is not None
577
+ # But the tool's .name is unchanged
578
+ assert stored.name == "my_tool"
579
+ # The tool is not accessible under its original name
580
+ assert manager.get_tool("my_tool") is None
581
+
582
+ async def test_call_tool_with_custom_name(self):
583
+ """Test calling a tool added with a custom name."""
584
+
585
+ def multiply(a: int, b: int) -> int:
586
+ """Multiply two numbers."""
587
+ return a * b
588
+
589
+ manager = ToolManager()
590
+ manager.add_tool_from_fn(multiply, name="custom_multiply")
591
+
592
+ # Tool should be callable by its custom name
593
+ result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
594
+ assert result == 15
595
+
596
+ # Original name should not be registered
597
+ with pytest.raises(ToolError):
598
+ await manager.call_tool("multiply", {"a": 5, "b": 3})
599
+
600
+ def test_tool_to_mcp_tool_with_custom_name(self):
601
+ """Test that to_mcp_tool uses the storage name, not the internal name."""
602
+
603
+ def some_function(x: int) -> int:
604
+ return x
605
+
606
+ manager = ToolManager()
607
+ manager.add_tool_from_fn(some_function, name="api_function")
608
+
609
+ # When listing tools for MCP, the custom name should be used
610
+ mcp_tools = manager.list_mcp_tools()
611
+ assert len(mcp_tools) == 1
612
+ assert mcp_tools[0].name == "api_function"
613
+
614
+ def test_import_tools_with_custom_names(self):
615
+ """Test importing tools with custom names."""
616
+
617
+ def source_fn(x: int) -> int:
618
+ return x * 2
619
+
620
+ # Create a source manager with a tool using custom name
621
+ source_manager = ToolManager()
622
+ source_manager.add_tool_from_fn(source_fn, name="custom_source")
623
+
624
+ # Import the tools to a target manager with a prefix
625
+ target_manager = ToolManager()
626
+ target_manager.import_tools(source_manager, "prefix/")
627
+
628
+ # The tool should be imported with the prefixed custom name
629
+ assert target_manager.get_tool("prefix/custom_source") is not None
630
+ assert target_manager.get_tool("prefix/source_fn") is None
631
+
632
+ def test_replace_tool_keeps_original_name(self):
633
+ """Test that replacing a tool with DuplicateBehavior.REPLACE keeps the original name."""
634
+
635
+ def original_fn(x: int) -> int:
636
+ return x
637
+
638
+ def replacement_fn(x: int) -> int:
639
+ return x * 2
640
+
641
+ # Create a manager with REPLACE behavior
642
+ manager = ToolManager(duplicate_behavior=DuplicateBehavior.REPLACE)
643
+
644
+ # Add the original tool
645
+ original_tool = manager.add_tool_from_fn(original_fn, name="test_tool")
646
+ assert original_tool.name == "test_tool"
647
+
648
+ # Replace with a new function but keep the same registered name
649
+ replacement_tool = manager.add_tool_from_fn(replacement_fn, name="test_tool")
650
+
651
+ # The tool object should have been replaced
652
+ stored_tool = manager.get_tool("test_tool")
653
+ assert stored_tool is not None
654
+ assert stored_tool == replacement_tool
655
+
656
+ # The name should still be the same
657
+ assert stored_tool.name == "test_tool"
658
+
659
+ # But the function is different
660
+ assert stored_tool.fn.__name__ == "replacement_fn"
661
+
662
+ def test_mcp_tool_name_for_add_tool(self):
663
+ """Test MCPTool name for add_tool (storage name != tool.name)."""
664
+
665
+ def fn(x: int) -> int:
666
+ return x + 1
667
+
668
+ tool = Tool.from_function(fn, name="my_tool")
669
+ manager = ToolManager()
670
+ manager.add_tool(tool, name="proxy_tool")
671
+ mcp_tools = manager.list_mcp_tools()
672
+ assert len(mcp_tools) == 1
673
+ assert mcp_tools[0].name == "proxy_tool"
674
+
675
+ def test_mcp_tool_name_for_add_tool_from_fn(self):
676
+ """Test MCPTool name for add_tool_from_fn (storage name == tool.name)."""
677
+
678
+ def fn(x: int) -> int:
679
+ return x + 1
680
+
681
+ manager = ToolManager()
682
+ manager.add_tool_from_fn(fn, name="custom_fn")
683
+ mcp_tools = manager.list_mcp_tools()
684
+ assert len(mcp_tools) == 1
685
+ assert mcp_tools[0].name == "custom_fn"