Repository: jlowin/fastmcp
Pull request #437 (2025-05-13 20:43:48)

feat: add support for removing tools from server
## What
Adds support for removing tools from the server at runtime.

## Why
This allows users to dynamically remove tools based on internal state or external logic, as suggested in issue #433

## How
- Added `ToolManager.remove_tool(key: str)`
- Added `FastMCP.remove_tool(name: str)` as a public API
- Cleared tool cache to maintain consistency
- Added unit tests to both ToolManager and FastMCP test suites

## Notes
- Method names and docstrings match existing project conventions
- Exception handling is consistent with other FastMCP behavior

All tests pass, and `pre-commit` checks are clean.


Patch:
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index b17b4ec..90499e9 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -457,6 +457,18 @@ class FastMCP(Generic[LifespanResultT]):
         )
         self._cache.clear()
 
+    def remove_tool(self, name: str) -> None:
+        """Remove a tool from the server.
+
+        Args:
+            name: The name of the tool to remove
+
+        Raises:
+            NotFoundError: If the tool is not found
+        """
+        self._tool_manager.remove_tool(name)
+        self._cache.clear()
+
     def tool(
         self,
         name: str | None = None,
diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py
index 21e38c5..509f4e6 100644
--- a/src/fastmcp/tools/tool_manager.py
+++ b/src/fastmcp/tools/tool_manager.py
@@ -94,6 +94,20 @@ class ToolManager:
             self._tools[key] = tool
         return tool
 
+    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"Unknown tool: {key}")
+
     async def call_tool(
         self, key: str, arguments: dict[str, Any]
     ) -> list[TextContent | ImageContent | EmbeddedResource]:


Test patch:
diff --git a/tests/server/test_server.py b/tests/server/test_server.py
index ead1dd6..f609596 100644
--- a/tests/server/test_server.py
+++ b/tests/server/test_server.py
@@ -72,6 +72,25 @@ class TestTools:
         assert len(mcp_tools) == 1
         assert mcp_tools[0].name == "custom_name"
 
+    async def test_remove_tool_successfully(self):
+        """Test that FastMCP.remove_tool removes the tool from the registry."""
+
+        mcp = FastMCP()
+
+        @mcp.tool(name="adder")
+        def add(a: int, b: int) -> int:
+            return a + b
+
+        mcp_tools = await mcp.get_tools()
+        assert "adder" in mcp_tools
+
+        mcp.remove_tool("adder")
+        mcp_tools = await mcp.get_tools()
+        assert "adder" not in mcp_tools
+
+        with pytest.raises(NotFoundError, match="Unknown tool: adder"):
+            await mcp._mcp_call_tool("adder", {"a": 1, "b": 2})
+
 
 class TestToolDecorator:
     async def test_no_tools_before_decorator(self):
diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py
index a777b75..9d35d17 100644
--- a/tests/tools/test_tool_manager.py
+++ b/tests/tools/test_tool_manager.py
@@ -100,6 +100,26 @@ class TestAddTools:
         ):
             manager.add_tool_from_fn(lambda x: x)
 
+    def test_remove_tool_successfully(self):
+        """Test removing an added tool by key."""
+        manager = ToolManager()
+
+        def add(a: int, b: int) -> int:
+            return a + b
+
+        manager.add_tool_from_fn(add)
+        assert manager.get_tool("add") is not None
+
+        manager.remove_tool("add")
+        with pytest.raises(NotFoundError):
+            manager.get_tool("add")
+
+    def test_remove_tool_missing_key(self):
+        """Test removing a tool that does not exist raises NotFoundError."""
+        manager = ToolManager()
+        with pytest.raises(NotFoundError, match=f"Unknown tool: {'missing'}"):
+            manager.remove_tool("missing")
+
     def test_warn_on_duplicate_tools(self, caplog):
         """Test warning on duplicate tools."""
         manager = ToolManager(duplicate_behavior="warn")