From 23ff11d3e79ce2c2723671d38053d01f0d137e4a Mon Sep 17 00:00:00 2001 From: brandonspark Date: Fri, 29 Aug 2025 11:21:59 -0700 Subject: [PATCH 1/5] remove tools --- src/mcp/server/fastmcp/tools/tool_manager.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index bfa8b2382..b3aa27fa4 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -68,6 +68,12 @@ def add_tool( self._tools[tool.name] = tool return tool + def remove_tool(self, name: str) -> None: + """Remove a tool by name.""" + if name not in self._tools: + logger.warning(f"Tried to remove unknown tool: {name}") + del self._tools[name] + async def call_tool( self, name: str, From c50a593e7363dc3a427be845600146f6e7824e23 Mon Sep 17 00:00:00 2001 From: brandonspark Date: Fri, 29 Aug 2025 11:30:38 -0700 Subject: [PATCH 2/5] add tests --- src/mcp/server/fastmcp/tools/tool_manager.py | 1 + tests/server/fastmcp/test_tool_manager.py | 114 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index b3aa27fa4..1323017dc 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -72,6 +72,7 @@ def remove_tool(self, name: str) -> None: """Remove a tool by name.""" if name not in self._tools: logger.warning(f"Tried to remove unknown tool: {name}") + return del self._tools[name] async def call_tool( diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index 8b6168275..110665d11 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -633,3 +633,117 @@ def get_scores() -> dict[str, int]: # Test converted result result = await manager.call_tool("get_scores", {}) assert result == expected + + +class TestRemoveTools: + """Test tool removal functionality in the tool manager.""" + + def test_remove_existing_tool(self, caplog: pytest.LogCaptureFixture): + """Test removing an existing tool.""" + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + manager = ToolManager() + manager.add_tool(add) + + # Verify tool exists + assert manager.get_tool("add") is not None + assert len(manager.list_tools()) == 1 + + # Remove the tool + with caplog.at_level(logging.WARNING): + manager.remove_tool("add") + # Should not log a warning for removing existing tool + assert "Tried to remove unknown tool: add" not in caplog.text + + # Verify tool is removed + assert manager.get_tool("add") is None + assert len(manager.list_tools()) == 0 + + def test_remove_nonexistent_tool(self, caplog: pytest.LogCaptureFixture): + """Test removing a non-existent tool logs a warning.""" + manager = ToolManager() + + with caplog.at_level(logging.WARNING): + manager.remove_tool("nonexistent") + assert "Tried to remove unknown tool: nonexistent" in caplog.text + + def test_remove_tool_from_multiple_tools(self): + """Test removing one tool when multiple tools exist.""" + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + def multiply(a: int, b: int) -> int: + """Multiply two numbers.""" + return a * b + + def divide(a: int, b: int) -> float: + """Divide two numbers.""" + return a / b + + manager = ToolManager() + manager.add_tool(add) + manager.add_tool(multiply) + manager.add_tool(divide) + + # Verify all tools exist + assert len(manager.list_tools()) == 3 + assert manager.get_tool("add") is not None + assert manager.get_tool("multiply") is not None + assert manager.get_tool("divide") is not None + + # Remove middle tool + manager.remove_tool("multiply") + + # Verify only multiply is removed + assert len(manager.list_tools()) == 2 + assert manager.get_tool("add") is not None + assert manager.get_tool("multiply") is None + assert manager.get_tool("divide") is not None + + @pytest.mark.anyio + async def test_call_removed_tool_raises_error(self): + """Test that calling a removed tool raises ToolError.""" + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + manager = ToolManager() + manager.add_tool(greet) + + # Verify tool works before removal + result = await manager.call_tool("greet", {"name": "World"}) + assert result == "Hello, World!" + + # Remove the tool + manager.remove_tool("greet") + + # Verify calling removed tool raises error + with pytest.raises(ToolError, match="Unknown tool: greet"): + await manager.call_tool("greet", {"name": "World"}) + + def test_remove_tool_case_sensitive(self, caplog: pytest.LogCaptureFixture): + """Test that tool removal is case-sensitive.""" + def test_func() -> str: + """Test function.""" + return "test" + + manager = ToolManager() + manager.add_tool(test_func) + + # Verify tool exists + assert manager.get_tool("test_func") is not None + + # Try to remove with different case + with caplog.at_level(logging.WARNING): + manager.remove_tool("Test_Func") + assert "Tried to remove unknown tool: Test_Func" in caplog.text + + # Verify original tool still exists + assert manager.get_tool("test_func") is not None + + # Remove with correct case + manager.remove_tool("test_func") + assert manager.get_tool("test_func") is None \ No newline at end of file From 42bd7949d4a859c4b294133d6475ca350f799a58 Mon Sep 17 00:00:00 2001 From: brandonspark Date: Fri, 29 Aug 2025 11:40:53 -0700 Subject: [PATCH 3/5] pre-commit --- tests/server/fastmcp/test_tool_manager.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index 110665d11..4090bed03 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -640,6 +640,7 @@ class TestRemoveTools: def test_remove_existing_tool(self, caplog: pytest.LogCaptureFixture): """Test removing an existing tool.""" + def add(a: int, b: int) -> int: """Add two numbers.""" return a + b @@ -671,6 +672,7 @@ def test_remove_nonexistent_tool(self, caplog: pytest.LogCaptureFixture): def test_remove_tool_from_multiple_tools(self): """Test removing one tool when multiple tools exist.""" + def add(a: int, b: int) -> int: """Add two numbers.""" return a + b @@ -706,6 +708,7 @@ def divide(a: int, b: int) -> float: @pytest.mark.anyio async def test_call_removed_tool_raises_error(self): """Test that calling a removed tool raises ToolError.""" + def greet(name: str) -> str: """Greet someone.""" return f"Hello, {name}!" @@ -726,6 +729,7 @@ def greet(name: str) -> str: def test_remove_tool_case_sensitive(self, caplog: pytest.LogCaptureFixture): """Test that tool removal is case-sensitive.""" + def test_func() -> str: """Test function.""" return "test" @@ -746,4 +750,4 @@ def test_func() -> str: # Remove with correct case manager.remove_tool("test_func") - assert manager.get_tool("test_func") is None \ No newline at end of file + assert manager.get_tool("test_func") is None From 0812b94ab70fa20f8ff31d8d3c0868acb48af72e Mon Sep 17 00:00:00 2001 From: brandonspark Date: Tue, 2 Sep 2025 09:59:08 -0700 Subject: [PATCH 4/5] raise on no tool --- src/mcp/server/fastmcp/tools/tool_manager.py | 3 +-- tests/server/fastmcp/test_tool_manager.py | 23 ++++++++------------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index 1323017dc..f06f9c26c 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -71,8 +71,7 @@ def add_tool( def remove_tool(self, name: str) -> None: """Remove a tool by name.""" if name not in self._tools: - logger.warning(f"Tried to remove unknown tool: {name}") - return + raise ToolError(f"Unknown tool: {name}") del self._tools[name] async def call_tool( diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index 4090bed03..71884fba2 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -638,7 +638,7 @@ def get_scores() -> dict[str, int]: class TestRemoveTools: """Test tool removal functionality in the tool manager.""" - def test_remove_existing_tool(self, caplog: pytest.LogCaptureFixture): + def test_remove_existing_tool(self): """Test removing an existing tool.""" def add(a: int, b: int) -> int: @@ -652,23 +652,19 @@ def add(a: int, b: int) -> int: assert manager.get_tool("add") is not None assert len(manager.list_tools()) == 1 - # Remove the tool - with caplog.at_level(logging.WARNING): - manager.remove_tool("add") - # Should not log a warning for removing existing tool - assert "Tried to remove unknown tool: add" not in caplog.text + # Remove the tool - should not raise any exception + manager.remove_tool("add") # Verify tool is removed assert manager.get_tool("add") is None assert len(manager.list_tools()) == 0 - def test_remove_nonexistent_tool(self, caplog: pytest.LogCaptureFixture): - """Test removing a non-existent tool logs a warning.""" + def test_remove_nonexistent_tool(self): + """Test removing a non-existent tool raises ToolError.""" manager = ToolManager() - with caplog.at_level(logging.WARNING): + with pytest.raises(ToolError, match="Unknown tool: nonexistent"): manager.remove_tool("nonexistent") - assert "Tried to remove unknown tool: nonexistent" in caplog.text def test_remove_tool_from_multiple_tools(self): """Test removing one tool when multiple tools exist.""" @@ -727,7 +723,7 @@ def greet(name: str) -> str: with pytest.raises(ToolError, match="Unknown tool: greet"): await manager.call_tool("greet", {"name": "World"}) - def test_remove_tool_case_sensitive(self, caplog: pytest.LogCaptureFixture): + def test_remove_tool_case_sensitive(self): """Test that tool removal is case-sensitive.""" def test_func() -> str: @@ -740,10 +736,9 @@ def test_func() -> str: # Verify tool exists assert manager.get_tool("test_func") is not None - # Try to remove with different case - with caplog.at_level(logging.WARNING): + # Try to remove with different case - should raise ToolError + with pytest.raises(ToolError, match="Unknown tool: Test_Func"): manager.remove_tool("Test_Func") - assert "Tried to remove unknown tool: Test_Func" in caplog.text # Verify original tool still exists assert manager.get_tool("test_func") is not None From 41084e9cc7aa06c3015dc11a1f29d01bec00e2dd Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 6 Oct 2025 14:08:15 +0100 Subject: [PATCH 5/5] feat: add remove_tool method to FastMCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a remove_tool method to the FastMCP class that allows tools to be dynamically removed from a server at runtime. This enables use cases where tools should only be available based on initialization state or user actions. The implementation includes: - remove_tool() method on FastMCP class that wraps the existing ToolManager.remove_tool() functionality - Comprehensive test coverage for tool removal scenarios - Proper error handling when attempting to remove non-existent tools 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Github-Issue: #1322 --- src/mcp/server/fastmcp/server.py | 11 +++++ tests/server/fastmcp/test_server.py | 74 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index cacf41dc2..abd03915b 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -374,6 +374,17 @@ def add_tool( structured_output=structured_output, ) + def remove_tool(self, name: str) -> None: + """Remove a tool from the server by name. + + Args: + name: The name of the tool to remove + + Raises: + ToolError: If the tool does not exist + """ + self._tool_manager.remove_tool(name) + def tool( self, name: str | None = None, diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 5e34ba1b1..8caa3b1f6 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -603,6 +603,80 @@ def get_settings() -> dict[str, str]: assert result.isError is False assert result.structuredContent == {"theme": "dark", "language": "en", "timezone": "UTC"} + @pytest.mark.anyio + async def test_remove_tool(self): + """Test removing a tool from the server.""" + mcp = FastMCP() + mcp.add_tool(tool_fn) + + # Verify tool exists + assert len(mcp._tool_manager.list_tools()) == 1 + + # Remove the tool + mcp.remove_tool("tool_fn") + + # Verify tool is removed + assert len(mcp._tool_manager.list_tools()) == 0 + + @pytest.mark.anyio + async def test_remove_nonexistent_tool(self): + """Test that removing a non-existent tool raises ToolError.""" + from mcp.server.fastmcp.exceptions import ToolError + + mcp = FastMCP() + + with pytest.raises(ToolError, match="Unknown tool: nonexistent"): + mcp.remove_tool("nonexistent") + + @pytest.mark.anyio + async def test_remove_tool_and_list(self): + """Test that a removed tool doesn't appear in list_tools.""" + mcp = FastMCP() + mcp.add_tool(tool_fn) + mcp.add_tool(error_tool_fn) + + # Verify both tools exist + async with client_session(mcp._mcp_server) as client: + tools = await client.list_tools() + assert len(tools.tools) == 2 + tool_names = [t.name for t in tools.tools] + assert "tool_fn" in tool_names + assert "error_tool_fn" in tool_names + + # Remove one tool + mcp.remove_tool("tool_fn") + + # Verify only one tool remains + async with client_session(mcp._mcp_server) as client: + tools = await client.list_tools() + assert len(tools.tools) == 1 + assert tools.tools[0].name == "error_tool_fn" + + @pytest.mark.anyio + async def test_remove_tool_and_call(self): + """Test that calling a removed tool fails appropriately.""" + mcp = FastMCP() + mcp.add_tool(tool_fn) + + # Verify tool works before removal + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) + assert not result.isError + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "3" + + # Remove the tool + mcp.remove_tool("tool_fn") + + # Verify calling removed tool returns an error + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) + assert result.isError + content = result.content[0] + assert isinstance(content, TextContent) + assert "Unknown tool" in content.text + class TestServerResources: @pytest.mark.anyio