11import io
22import json
33import os
4+ from types import SimpleNamespace
45from unittest .mock import AsyncMock , MagicMock , patch
56
67import pytest
1314
1415from haystack_integrations .tools .mcp import (
1516 MCPTool ,
17+ MCPToolNotFoundError ,
1618 StdioServerInfo ,
1719)
1820from haystack_integrations .tools .mcp .mcp_tool import StdioClient , _extract_first_text_element
1921
2022from .mcp_memory_transport import InMemoryServerInfo
21- from .mcp_servers_fixtures import calculator_mcp , echo_mcp
23+ from .mcp_servers_fixtures import calculator_mcp , echo_mcp , image_mcp , state_calculator_mcp
2224
2325
2426@tool
@@ -104,6 +106,41 @@ def test_mcp_tool_invoke(self, mcp_add_tool, mcp_echo_tool):
104106 echo_result = json .loads (echo_result )
105107 assert echo_result ["content" ][0 ]["text" ] == "Hello MCP!"
106108
109+ def test_mcp_tool_outputs_to_state_falls_back_to_full_response_for_non_text_content (self , mcp_tool_cleanup ):
110+ """Test that non-text MCP content returns the full parsed response when state output is enabled."""
111+ server_info = InMemoryServerInfo (server = image_mcp ._mcp_server )
112+ tool = MCPTool (
113+ name = "image_tool" ,
114+ server_info = server_info ,
115+ eager_connect = True ,
116+ outputs_to_state = {"image_payload" : {}},
117+ )
118+ mcp_tool_cleanup (tool )
119+
120+ result = tool .invoke ()
121+
122+ assert isinstance (result , dict )
123+ assert len (result ["content" ]) == 1
124+ assert result ["content" ][0 ]["type" ] == "image"
125+ assert result ["content" ][0 ]["data" ] == "ZmFrZQ=="
126+ assert result ["content" ][0 ]["mimeType" ] == "image/png"
127+ assert result ["isError" ] is False
128+
129+ def test_mcp_tool_outputs_to_state_returns_raw_text_when_text_is_not_json (self , mcp_tool_cleanup ):
130+ """Test that plain text content is returned as-is when state output parsing cannot decode JSON."""
131+ server_info = InMemoryServerInfo (server = echo_mcp ._mcp_server )
132+ tool = MCPTool (
133+ name = "echo" ,
134+ server_info = server_info ,
135+ eager_connect = True ,
136+ outputs_to_state = {"echo_payload" : {}},
137+ )
138+ mcp_tool_cleanup (tool )
139+
140+ result = tool .invoke (text = "Hello MCP!" )
141+
142+ assert result == "Hello MCP!"
143+
107144 def test_mcp_tool_error_handling (self , mcp_error_tool ):
108145 """Test error handling with the in-memory server."""
109146 with pytest .raises (ToolInvocationError ) as exc_info :
@@ -114,6 +151,47 @@ def test_mcp_tool_error_handling(self, mcp_error_tool):
114151 # The first part of the message comes from ToolInvocationError's formatting
115152 assert "Failed to invoke Tool `divide_by_zero`" in error_message
116153
154+ def test_mcp_tool_lazy_missing_tool_raises_with_available_tools (self , mcp_tool_cleanup ):
155+ """Test that lazy warm-up surfaces missing-tool errors with the available tool names."""
156+ server_info = InMemoryServerInfo (server = calculator_mcp ._mcp_server )
157+ tool = MCPTool (name = "multiply" , server_info = server_info , eager_connect = False )
158+ mcp_tool_cleanup (tool )
159+
160+ mock_worker = MagicMock ()
161+ mock_worker .tools .return_value = [
162+ SimpleNamespace (name = "add" ),
163+ SimpleNamespace (name = "subtract" ),
164+ SimpleNamespace (name = "divide_by_zero" ),
165+ ]
166+
167+ with (
168+ patch ("haystack_integrations.tools.mcp.mcp_tool._MCPClientSessionManager" , return_value = mock_worker ),
169+ pytest .raises (MCPToolNotFoundError ) as exc_info ,
170+ ):
171+ tool .warm_up ()
172+
173+ assert exc_info .value .tool_name == "multiply"
174+ assert set (exc_info .value .available_tools ) == {"add" , "subtract" , "divide_by_zero" }
175+
176+ def test_mcp_tool_lazy_no_tools_server_raises_tool_not_found (self , mcp_tool_cleanup ):
177+ """Test that lazy warm-up fails cleanly when the server exposes no tools."""
178+ server_info = InMemoryServerInfo (server = calculator_mcp ._mcp_server )
179+ tool = MCPTool (name = "anything" , server_info = server_info , eager_connect = False )
180+ mcp_tool_cleanup (tool )
181+
182+ mock_worker = MagicMock ()
183+ mock_worker .tools .return_value = []
184+
185+ with (
186+ patch ("haystack_integrations.tools.mcp.mcp_tool._MCPClientSessionManager" , return_value = mock_worker ),
187+ pytest .raises (MCPToolNotFoundError ) as exc_info ,
188+ ):
189+ tool .warm_up ()
190+
191+ assert str (exc_info .value ) == "No tools available on server"
192+ assert exc_info .value .tool_name == "anything"
193+ assert exc_info .value .available_tools == []
194+
117195 def test_mcp_tool_serde (self , mcp_tool_cleanup ):
118196 """Test serialization and deserialization of MCPTool with in-memory server."""
119197 server_info = InMemoryServerInfo (server = calculator_mcp ._mcp_server )
@@ -186,6 +264,22 @@ def test_mcp_tool_state_mapping_parameters(self, mcp_tool_cleanup):
186264 assert "b" in tool .parameters ["properties" ]
187265 assert "b" in tool .parameters ["required" ]
188266
267+ def test_mcp_tool_eager_state_mapping_removes_inputs_from_schema (self , mcp_tool_cleanup ):
268+ """Test that eager MCPTool initialization removes state-injected params from its public schema."""
269+ server_info = InMemoryServerInfo (server = calculator_mcp ._mcp_server )
270+ tool = MCPTool (
271+ name = "add" ,
272+ server_info = server_info ,
273+ eager_connect = True ,
274+ inputs_from_state = {"state_a" : "a" },
275+ )
276+ mcp_tool_cleanup (tool )
277+
278+ assert "a" not in tool .parameters ["properties" ]
279+ assert "a" not in tool .parameters .get ("required" , [])
280+ assert "b" in tool .parameters ["properties" ]
281+ assert "b" in tool .parameters ["required" ]
282+
189283 def test_mcp_tool_serde_with_state_mapping (self , mcp_tool_cleanup ):
190284 """Test serialization and deserialization of MCPTool with state-mapping parameters."""
191285 server_info = InMemoryServerInfo (server = calculator_mcp ._mcp_server )
@@ -219,6 +313,62 @@ def test_mcp_tool_serde_with_state_mapping(self, mcp_tool_cleanup):
219313 assert new_tool ._inputs_from_state == {"state_a" : "a" }
220314 assert new_tool ._outputs_to_state == {"result" : {"source" : "output" }}
221315
316+ @pytest .mark .skipif (
317+ not hasattr (__import__ ("haystack.tools" , fromlist = ["Tool" ]).Tool , "_get_valid_inputs" ),
318+ reason = "Requires Haystack >= 2.22.0 for inputs_from_state validation" ,
319+ )
320+ def test_mcp_tool_lazy_invalid_parameter_raises_on_warm_up (self , mcp_tool_cleanup ):
321+ """Test that lazy MCPTool defers invalid inputs_from_state validation until warm_up()."""
322+ server_info = InMemoryServerInfo (server = calculator_mcp ._mcp_server )
323+ tool = MCPTool (
324+ name = "add" ,
325+ server_info = server_info ,
326+ eager_connect = False ,
327+ inputs_from_state = {"state_key" : "non_existent_param" },
328+ )
329+ mcp_tool_cleanup (tool )
330+
331+ assert tool .parameters == {"type" : "object" , "properties" : {}, "additionalProperties" : True }
332+
333+ with pytest .raises (ValueError , match = "unknown parameter" ):
334+ tool .warm_up ()
335+
336+ def test_mcp_tool_invoke_auto_warms_up_once (self , mcp_tool_cleanup ):
337+ """Test that lazy MCPTool initializes on first invoke and reuses that connection."""
338+ server_info = InMemoryServerInfo (server = calculator_mcp ._mcp_server )
339+ tool = MCPTool (name = "add" , server_info = server_info , eager_connect = False )
340+ mcp_tool_cleanup (tool )
341+
342+ assert tool .parameters == {"type" : "object" , "properties" : {}, "additionalProperties" : True }
343+
344+ with patch .object (tool , "_connect_and_initialize" , wraps = tool ._connect_and_initialize ) as mock_connect :
345+ first_result = json .loads (tool .invoke (a = 20 , b = 22 ))
346+ second_result = json .loads (tool .invoke (a = 1 , b = 2 ))
347+
348+ assert first_result ["content" ][0 ]["text" ] == "42"
349+ assert second_result ["content" ][0 ]["text" ] == "3"
350+ assert "a" in tool .parameters ["properties" ]
351+ assert "b" in tool .parameters ["properties" ]
352+ assert mock_connect .call_count == 1
353+
354+ @pytest .mark .asyncio
355+ async def test_mcp_tool_ainvoke_matches_invoke_with_outputs_to_state (self , mcp_tool_cleanup ):
356+ """Test that sync and async invocation paths return the same parsed state output."""
357+ server_info = InMemoryServerInfo (server = state_calculator_mcp ._mcp_server )
358+ tool = MCPTool (
359+ name = "state_add" ,
360+ server_info = server_info ,
361+ eager_connect = True ,
362+ outputs_to_state = {"result" : {"source" : "result" }},
363+ )
364+ mcp_tool_cleanup (tool )
365+
366+ sync_result = tool .invoke (a = 20 , b = 22 )
367+ async_result = await tool .ainvoke (a = 20 , b = 22 )
368+
369+ assert sync_result == {"result" : 42 }
370+ assert async_result == sync_result
371+
222372 @pytest .mark .asyncio
223373 @pytest .mark .parametrize (
224374 "fileno_side_effect,fileno_return_value,notebook_environment" ,
@@ -255,6 +405,24 @@ async def test_stdio_client_stderr_handling(self, fileno_side_effect, fileno_ret
255405 else :
256406 assert errlog is mock_stderr
257407
408+ @pytest .mark .asyncio
409+ async def test_mcp_client_aclose_clears_references_even_when_cleanup_fails (self , caplog ):
410+ """Test that client cleanup always clears connection state, even if exit_stack cleanup raises."""
411+ client = StdioClient (command = "echo" )
412+ client .session = MagicMock ()
413+ client .stdio = MagicMock ()
414+ client .write = MagicMock ()
415+ client .exit_stack = MagicMock ()
416+ client .exit_stack .aclose = AsyncMock (side_effect = RuntimeError ("cleanup failed" ))
417+
418+ with caplog .at_level ("WARNING" ):
419+ await client .aclose ()
420+
421+ assert any ("Error during MCP client cleanup: cleanup failed" in record .message for record in caplog .records )
422+ assert client .session is None
423+ assert client .stdio is None
424+ assert client .write is None
425+
258426 @pytest .mark .skipif (not os .environ .get ("OPENAI_API_KEY" ), reason = "OPENAI_API_KEY not set" )
259427 @pytest .mark .integration
260428 def test_pipeline_warmup_with_mcp_tool (self ):
0 commit comments