Skip to content

Commit b8e758b

Browse files
brandonsparkdsp-antfelixweinbergermaxisbeyclaude
authored
feat: add ability to remove tools (#1322)
Co-authored-by: David Soria Parra <[email protected]> Co-authored-by: Felix Weinberger <[email protected]> Co-authored-by: Max Isbey <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent df3e428 commit b8e758b

File tree

4 files changed

+204
-0
lines changed

4 files changed

+204
-0
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,17 @@ def add_tool(
391391
structured_output=structured_output,
392392
)
393393

394+
def remove_tool(self, name: str) -> None:
395+
"""Remove a tool from the server by name.
396+
397+
Args:
398+
name: The name of the tool to remove
399+
400+
Raises:
401+
ToolError: If the tool does not exist
402+
"""
403+
self._tool_manager.remove_tool(name)
404+
394405
def tool(
395406
self,
396407
name: str | None = None,

src/mcp/server/fastmcp/tools/tool_manager.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ def add_tool(
7070
self._tools[tool.name] = tool
7171
return tool
7272

73+
def remove_tool(self, name: str) -> None:
74+
"""Remove a tool by name."""
75+
if name not in self._tools:
76+
raise ToolError(f"Unknown tool: {name}")
77+
del self._tools[name]
78+
7379
async def call_tool(
7480
self,
7581
name: str,

tests/server/fastmcp/test_server.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,80 @@ def get_settings() -> dict[str, str]:
603603
assert result.isError is False
604604
assert result.structuredContent == {"theme": "dark", "language": "en", "timezone": "UTC"}
605605

606+
@pytest.mark.anyio
607+
async def test_remove_tool(self):
608+
"""Test removing a tool from the server."""
609+
mcp = FastMCP()
610+
mcp.add_tool(tool_fn)
611+
612+
# Verify tool exists
613+
assert len(mcp._tool_manager.list_tools()) == 1
614+
615+
# Remove the tool
616+
mcp.remove_tool("tool_fn")
617+
618+
# Verify tool is removed
619+
assert len(mcp._tool_manager.list_tools()) == 0
620+
621+
@pytest.mark.anyio
622+
async def test_remove_nonexistent_tool(self):
623+
"""Test that removing a non-existent tool raises ToolError."""
624+
from mcp.server.fastmcp.exceptions import ToolError
625+
626+
mcp = FastMCP()
627+
628+
with pytest.raises(ToolError, match="Unknown tool: nonexistent"):
629+
mcp.remove_tool("nonexistent")
630+
631+
@pytest.mark.anyio
632+
async def test_remove_tool_and_list(self):
633+
"""Test that a removed tool doesn't appear in list_tools."""
634+
mcp = FastMCP()
635+
mcp.add_tool(tool_fn)
636+
mcp.add_tool(error_tool_fn)
637+
638+
# Verify both tools exist
639+
async with client_session(mcp._mcp_server) as client:
640+
tools = await client.list_tools()
641+
assert len(tools.tools) == 2
642+
tool_names = [t.name for t in tools.tools]
643+
assert "tool_fn" in tool_names
644+
assert "error_tool_fn" in tool_names
645+
646+
# Remove one tool
647+
mcp.remove_tool("tool_fn")
648+
649+
# Verify only one tool remains
650+
async with client_session(mcp._mcp_server) as client:
651+
tools = await client.list_tools()
652+
assert len(tools.tools) == 1
653+
assert tools.tools[0].name == "error_tool_fn"
654+
655+
@pytest.mark.anyio
656+
async def test_remove_tool_and_call(self):
657+
"""Test that calling a removed tool fails appropriately."""
658+
mcp = FastMCP()
659+
mcp.add_tool(tool_fn)
660+
661+
# Verify tool works before removal
662+
async with client_session(mcp._mcp_server) as client:
663+
result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
664+
assert not result.isError
665+
content = result.content[0]
666+
assert isinstance(content, TextContent)
667+
assert content.text == "3"
668+
669+
# Remove the tool
670+
mcp.remove_tool("tool_fn")
671+
672+
# Verify calling removed tool returns an error
673+
async with client_session(mcp._mcp_server) as client:
674+
result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
675+
assert result.isError
676+
content = result.content[0]
677+
assert isinstance(content, TextContent)
678+
assert "Unknown tool" in content.text
679+
606680

607681
class TestServerResources:
608682
@pytest.mark.anyio

tests/server/fastmcp/test_tool_manager.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,3 +633,116 @@ def get_scores() -> dict[str, int]:
633633
# Test converted result
634634
result = await manager.call_tool("get_scores", {})
635635
assert result == expected
636+
637+
638+
class TestRemoveTools:
639+
"""Test tool removal functionality in the tool manager."""
640+
641+
def test_remove_existing_tool(self):
642+
"""Test removing an existing tool."""
643+
644+
def add(a: int, b: int) -> int:
645+
"""Add two numbers."""
646+
return a + b
647+
648+
manager = ToolManager()
649+
manager.add_tool(add)
650+
651+
# Verify tool exists
652+
assert manager.get_tool("add") is not None
653+
assert len(manager.list_tools()) == 1
654+
655+
# Remove the tool - should not raise any exception
656+
manager.remove_tool("add")
657+
658+
# Verify tool is removed
659+
assert manager.get_tool("add") is None
660+
assert len(manager.list_tools()) == 0
661+
662+
def test_remove_nonexistent_tool(self):
663+
"""Test removing a non-existent tool raises ToolError."""
664+
manager = ToolManager()
665+
666+
with pytest.raises(ToolError, match="Unknown tool: nonexistent"):
667+
manager.remove_tool("nonexistent")
668+
669+
def test_remove_tool_from_multiple_tools(self):
670+
"""Test removing one tool when multiple tools exist."""
671+
672+
def add(a: int, b: int) -> int:
673+
"""Add two numbers."""
674+
return a + b
675+
676+
def multiply(a: int, b: int) -> int:
677+
"""Multiply two numbers."""
678+
return a * b
679+
680+
def divide(a: int, b: int) -> float:
681+
"""Divide two numbers."""
682+
return a / b
683+
684+
manager = ToolManager()
685+
manager.add_tool(add)
686+
manager.add_tool(multiply)
687+
manager.add_tool(divide)
688+
689+
# Verify all tools exist
690+
assert len(manager.list_tools()) == 3
691+
assert manager.get_tool("add") is not None
692+
assert manager.get_tool("multiply") is not None
693+
assert manager.get_tool("divide") is not None
694+
695+
# Remove middle tool
696+
manager.remove_tool("multiply")
697+
698+
# Verify only multiply is removed
699+
assert len(manager.list_tools()) == 2
700+
assert manager.get_tool("add") is not None
701+
assert manager.get_tool("multiply") is None
702+
assert manager.get_tool("divide") is not None
703+
704+
@pytest.mark.anyio
705+
async def test_call_removed_tool_raises_error(self):
706+
"""Test that calling a removed tool raises ToolError."""
707+
708+
def greet(name: str) -> str:
709+
"""Greet someone."""
710+
return f"Hello, {name}!"
711+
712+
manager = ToolManager()
713+
manager.add_tool(greet)
714+
715+
# Verify tool works before removal
716+
result = await manager.call_tool("greet", {"name": "World"})
717+
assert result == "Hello, World!"
718+
719+
# Remove the tool
720+
manager.remove_tool("greet")
721+
722+
# Verify calling removed tool raises error
723+
with pytest.raises(ToolError, match="Unknown tool: greet"):
724+
await manager.call_tool("greet", {"name": "World"})
725+
726+
def test_remove_tool_case_sensitive(self):
727+
"""Test that tool removal is case-sensitive."""
728+
729+
def test_func() -> str:
730+
"""Test function."""
731+
return "test"
732+
733+
manager = ToolManager()
734+
manager.add_tool(test_func)
735+
736+
# Verify tool exists
737+
assert manager.get_tool("test_func") is not None
738+
739+
# Try to remove with different case - should raise ToolError
740+
with pytest.raises(ToolError, match="Unknown tool: Test_Func"):
741+
manager.remove_tool("Test_Func")
742+
743+
# Verify original tool still exists
744+
assert manager.get_tool("test_func") is not None
745+
746+
# Remove with correct case
747+
manager.remove_tool("test_func")
748+
assert manager.get_tool("test_func") is None

0 commit comments

Comments
 (0)