Skip to content

Commit 656080c

Browse files
authored
feat: make connection to Stdio MCP servers work on notebooks by redirecting errlog (#3037)
* feat: make connection to Stdio MCP servers work on notebooks by redirecting errlog * revert changes to streamablehttp_client * more
1 parent 5e5e7a7 commit 656080c

2 files changed

Lines changed: 53 additions & 1 deletion

File tree

integrations/mcp/src/haystack_integrations/tools/mcp/mcp_tool.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44

55
import asyncio
66
import concurrent.futures
7+
import io
78
import json
9+
import sys
10+
import tempfile
811
import threading
912
import warnings
1013
from abc import ABC, abstractmethod
@@ -435,7 +438,17 @@ async def connect(self) -> list[types.Tool]:
435438
logger.debug(f"PROCESS: Connecting to stdio server with command: {self.command}")
436439

437440
server_params = StdioServerParameters(command=self.command, args=self.args, env=self.env)
438-
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
441+
442+
# In notebook environments, sys.stderr is a custom object without a real file descriptor, which causes MCP stdio
443+
# connection to fail. We detect this and set the MCP server's errlog to a temp file instead.
444+
errlog = sys.stderr
445+
try:
446+
sys.stderr.fileno()
447+
except (io.UnsupportedOperation, AttributeError, OSError):
448+
errlog = tempfile.NamedTemporaryFile(mode="w", suffix="-mcp-stderr.log", delete=False)
449+
logger.warning("MCP server stderr redirected to {path}", path=errlog.name)
450+
451+
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params, errlog=errlog))
439452
return await self._initialize_session_with_transport(stdio_transport, f"stdio server (command: {self.command})")
440453

441454

integrations/mcp/tests/test_mcp_tool.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import io
12
import json
23
import os
4+
from unittest.mock import AsyncMock, MagicMock, patch
35

46
import pytest
57
from haystack.components.agents import Agent
@@ -13,6 +15,7 @@
1315
MCPTool,
1416
StdioServerInfo,
1517
)
18+
from haystack_integrations.tools.mcp.mcp_tool import StdioClient
1619

1720
from .mcp_memory_transport import InMemoryServerInfo
1821
from .mcp_servers_fixtures import calculator_mcp, echo_mcp
@@ -197,6 +200,42 @@ def test_mcp_tool_serde_with_state_mapping(self, mcp_tool_cleanup):
197200
assert new_tool._inputs_from_state == {"state_a": "a"}
198201
assert new_tool._outputs_to_state == {"result": {"source": "output"}}
199202

203+
@pytest.mark.asyncio
204+
@pytest.mark.parametrize(
205+
"fileno_side_effect,fileno_return_value,notebook_environment",
206+
[
207+
(io.UnsupportedOperation("fileno"), None, True),
208+
(None, 2, False),
209+
],
210+
)
211+
async def test_stdio_client_stderr_handling(self, fileno_side_effect, fileno_return_value, notebook_environment):
212+
"""Test that StdioClient uses sys.stderr in terminals and falls back to a file in notebooks."""
213+
client = StdioClient(command="echo", args=["hello"])
214+
215+
mock_stderr = MagicMock()
216+
mock_stderr.fileno.side_effect = fileno_side_effect
217+
mock_stderr.fileno.return_value = fileno_return_value
218+
219+
with (
220+
patch.object(client, "exit_stack") as mock_stack,
221+
patch("haystack_integrations.tools.mcp.mcp_tool.stdio_client") as mock_stdio_client,
222+
patch("haystack_integrations.tools.mcp.mcp_tool.sys") as mock_sys,
223+
patch.object(client, "_initialize_session_with_transport", new_callable=AsyncMock) as mock_init,
224+
):
225+
mock_sys.stderr = mock_stderr
226+
mock_stack.enter_async_context = AsyncMock(return_value=(MagicMock(), MagicMock()))
227+
mock_init.return_value = []
228+
229+
await client.connect()
230+
231+
_, kwargs = mock_stdio_client.call_args
232+
errlog = kwargs["errlog"]
233+
if notebook_environment:
234+
assert errlog is not mock_stderr
235+
assert hasattr(errlog, "write")
236+
else:
237+
assert errlog is mock_stderr
238+
200239
@pytest.mark.skipif("OPENAI_API_KEY" not in os.environ, reason="OPENAI_API_KEY not set")
201240
@pytest.mark.integration
202241
def test_pipeline_warmup_with_mcp_tool(self):

0 commit comments

Comments
 (0)