Repository: deepset-ai/haystack

Issue:
ToolInvoker needs to register tool names after warmup for non-eager tools
## Describe the bug

When using `ToolInvoker` with tools that require warmup (such as `MCPToolset` with `eager_connect=False`), the tool names are not properly registered in `ToolInvoker._tools_with_names` after warmup completes. This causes `ToolNotFoundException` errors when attempting to invoke tools, even though the tools have been successfully warmed up and are available.

The issue occurs because:
1. During `ToolInvoker.__init__()`, `_tools_with_names` is populated immediately with whatever tools are available at that time
2. For non-eager tools like `MCPToolset` (with `eager_connect=False`), placeholder tools are injected during initialization
3. When `ToolInvoker.warm_up()` is called, it warms up the tools (which will replace placeholder tools with actual tools), but it does not refresh `_tools_with_names` to reflect the post-warmup state
4. When `ToolInvoker.run()` executes, it uses the stale `_tools_with_names` dictionary, which may contain placeholder tool names instead of actual tool names

## Error message

```
ToolNotFoundException: Tool '<actual_tool_name>' not found. Available tools: ['mcp_not_connected_placeholder_<id>']
```

Or similar errors indicating that the tool invoker cannot find tools that should be available after warmup.

## Expected behavior

After `ToolInvoker.warm_up()` completes, `_tools_with_names` should be refreshed to contain the actual tool names from the warmed-up tools. This ensures that when `ToolInvoker.run()` is called, it can correctly locate and invoke the tools.

## Additional context

This issue affects any tool or toolset that:
- Requires warmup to establish connections or load schemas
- Uses lazy/non-eager initialization (e.g., `MCPToolset` with `eager_connect=False`)
- Replaces placeholder tools with actual tools during warmup

The fix requires one line change: 
`self._tools_with_names = self._validate_and_prepare_tools(self.tools)` 
in the `ToolInvoker.warm_up()` method after calling `warm_up_tools(self.tools)`, so that the tool name registry is updated after tools have been properly initialized.

## To Reproduce

1. Create an `MCPToolset` with `eager_connect=False` (the default):
```python
from haystack_integrations.tools.mcp import MCPToolset, StdioServerInfo

server_info = StdioServerInfo(command="uvx", args=["mcp-server-time", "--local-timezone=Europe/Berlin"])
mcp_toolset = MCPToolset(server_info)  # eager_connect=False by default
```

2. Create a pipeline with `ChatGenerator` and `ToolInvoker`:
```python
from haystack import Pipeline
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.tools import ToolInvoker

pipeline = Pipeline()
pipeline.add_component("llm", OpenAIChatGenerator(model="gpt-4o-mini", tools=mcp_toolset))
pipeline.add_component("tool_invoker", ToolInvoker(tools=mcp_toolset))
# ... rest of pipeline setup
```

3. Run the pipeline (which triggers warmup):
```python
result = pipeline.run({"llm": {"messages": [user_input_msg]}})
```

4. The pipeline will fail with `ToolNotFoundException` when trying to invoke tools, even though the tools have been warmed up and are available.

This doesn't happen with Agents but with the above Pipeline setup. 


## FAQ Check

- [x] Have you had a look at [our new FAQ page](https://docs.haystack.deepset.ai/docs/faq)?

## System:

 - OS: macOS / Linux / Windows (all affected)
 - GPU/CPU: N/A
 - Haystack version (commit or version number): Latest (issue exists in current codebase)
 - DocumentStore: N/A
 - Reader: N/A
 - Retriever: N/A

Discussion:

Fix (patch):
diff --git a/haystack/components/tools/tool_invoker.py b/haystack/components/tools/tool_invoker.py
index ab84e3bab7..5c49d1371a 100644
--- a/haystack/components/tools/tool_invoker.py
+++ b/haystack/components/tools/tool_invoker.py
@@ -496,6 +496,8 @@ def warm_up(self):
         """
         if not self._is_warmed_up:
             warm_up_tools(self.tools)
+            # tools could have been updated by the warm_up, validate/prepare for invocation
+            self._tools_with_names = self._validate_and_prepare_tools(self.tools)
             self._is_warmed_up = True
 
     @component.output_types(tool_messages=list[ChatMessage], state=State)
diff --git a/releasenotes/notes/fix-tool-invoker-warmup-tool-registry-cda6e84af3387ac8.yaml b/releasenotes/notes/fix-tool-invoker-warmup-tool-registry-cda6e84af3387ac8.yaml
new file mode 100644
index 0000000000..1cc1900a24
--- /dev/null
+++ b/releasenotes/notes/fix-tool-invoker-warmup-tool-registry-cda6e84af3387ac8.yaml
@@ -0,0 +1,7 @@
+---
+fixes:
+  - |
+    Fixed ToolInvoker missing tools after warmup for lazy-initialized toolsets.
+    The invoker now refreshes its tool registry post-warmup, ensuring replaced
+    placeholders (e.g., MCPToolset with eager_connect=False) resolve to the
+    actual tool names at invocation time.


Test changes:
diff --git a/test/components/tools/test_tool_invoker.py b/test/components/tools/test_tool_invoker.py
index c3c1c329e8..dc4f94b27a 100644
--- a/test/components/tools/test_tool_invoker.py
+++ b/test/components/tools/test_tool_invoker.py
@@ -4,6 +4,7 @@
 
 import datetime
 import json
+import os
 import time
 from typing import Any
 from unittest.mock import patch
@@ -1171,3 +1172,54 @@ def warm_up(self):
 
         # Should only be warmed up once
         assert tool.warm_up_count == 1
+
+    def test_warm_up_refreshes_tools_with_names(self):
+        """
+        Test that ToolInvoker.warm_up() refreshes _tools_with_names when using a toolset with lazy connection.
+        """
+        # Create placeholder tool that simulates MCPToolset behavior of lazy connection
+        placeholder_tool = Tool(
+            name="mcp_not_connected_placeholder_123",
+            description="Placeholder tool before connection",
+            parameters={"type": "object", "properties": {}},
+            function=lambda: "placeholder",
+        )
+
+        # Create the actual tool that will replace the placeholder during warmup
+        # This simulates what mcp-server-time mcp would provide
+        actual_tool = Tool(
+            name="get_time",
+            description="Get the current time in ISO format",
+            parameters={"type": "object", "properties": {}, "required": []},
+            function=lambda: "2024-12-01T12:00:00Z",
+        )
+
+        # Create a toolset that simulates MCPToolset with eager_connect=False (lazy connection)
+        class MockMCPToolset(Toolset):
+            """Simulates MCPToolset behavior with eager_connect=False."""
+
+            def __init__(self):
+                # Start with placeholder tools (like MCPToolset does when not eagerly connected)
+                super().__init__([placeholder_tool])
+                self._warmed_up = False
+
+            def warm_up(self):
+                """Simulate connecting to MCP server and replacing placeholder tools with actual tools."""
+                if not self._warmed_up:
+                    # Replace placeholder tools with actual tools (simulating MCP connection)
+                    self.tools = [actual_tool]
+                    self._warmed_up = True
+
+        mcp_toolset = MockMCPToolset()
+        invoker = ToolInvoker(tools=mcp_toolset)
+
+        # Before warmup: _tools_with_names should contain the placeholder tool
+        assert "mcp_not_connected_placeholder_123" in invoker._tools_with_names
+        assert "get_time" not in invoker._tools_with_names
+
+        # Call warm_up() directly to trigger tool refresh
+        invoker.warm_up()
+
+        # After warmup: _tools_with_names should be refreshed with actual tool names
+        assert "mcp_not_connected_placeholder_123" not in invoker._tools_with_names
+        assert "get_time" in invoker._tools_with_names