Skip to content

Commit f5dd324

Browse files
TimChildihrpr
andauthored
Prevent stdio connection hang for missing server path. (#401)
Co-authored-by: ihrpr <inna@anthropic.com>
1 parent 70014a2 commit f5dd324

File tree

2 files changed

+80
-20
lines changed

2 files changed

+80
-20
lines changed

src/mcp/client/stdio/__init__.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -108,20 +108,28 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
108108
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
109109
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
110110

111-
command = _get_executable_command(server.command)
112-
113-
# Open process with stderr piped for capture
114-
process = await _create_platform_compatible_process(
115-
command=command,
116-
args=server.args,
117-
env=(
118-
{**get_default_environment(), **server.env}
119-
if server.env is not None
120-
else get_default_environment()
121-
),
122-
errlog=errlog,
123-
cwd=server.cwd,
124-
)
111+
try:
112+
command = _get_executable_command(server.command)
113+
114+
# Open process with stderr piped for capture
115+
process = await _create_platform_compatible_process(
116+
command=command,
117+
args=server.args,
118+
env=(
119+
{**get_default_environment(), **server.env}
120+
if server.env is not None
121+
else get_default_environment()
122+
),
123+
errlog=errlog,
124+
cwd=server.cwd,
125+
)
126+
except OSError:
127+
# Clean up streams if process creation fails
128+
await read_stream.aclose()
129+
await write_stream.aclose()
130+
await read_stream_writer.aclose()
131+
await write_stream_reader.aclose()
132+
raise
125133

126134
async def stdout_reader():
127135
assert process.stdout, "Opened process is missing stdout"
@@ -177,12 +185,18 @@ async def stdin_writer():
177185
yield read_stream, write_stream
178186
finally:
179187
# Clean up process to prevent any dangling orphaned processes
180-
if sys.platform == "win32":
181-
await terminate_windows_process(process)
182-
else:
183-
process.terminate()
188+
try:
189+
if sys.platform == "win32":
190+
await terminate_windows_process(process)
191+
else:
192+
process.terminate()
193+
except ProcessLookupError:
194+
# Process already exited, which is fine
195+
pass
184196
await read_stream.aclose()
185197
await write_stream.aclose()
198+
await read_stream_writer.aclose()
199+
await write_stream_reader.aclose()
186200

187201

188202
def _get_executable_command(command: str) -> str:

tests/client/test_stdio.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22

33
import pytest
44

5-
from mcp.client.stdio import StdioServerParameters, stdio_client
5+
from mcp.client.session import ClientSession
6+
from mcp.client.stdio import (
7+
StdioServerParameters,
8+
stdio_client,
9+
)
10+
from mcp.shared.exceptions import McpError
611
from mcp.shared.message import SessionMessage
7-
from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse
12+
from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse
813

914
tee: str = shutil.which("tee") # type: ignore
15+
python: str = shutil.which("python") # type: ignore
1016

1117

1218
@pytest.mark.anyio
@@ -50,3 +56,43 @@ async def test_stdio_client():
5056
assert read_messages[1] == JSONRPCMessage(
5157
root=JSONRPCResponse(jsonrpc="2.0", id=2, result={})
5258
)
59+
60+
61+
@pytest.mark.anyio
62+
async def test_stdio_client_bad_path():
63+
"""Check that the connection doesn't hang if process errors."""
64+
server_params = StdioServerParameters(
65+
command="python", args=["-c", "non-existent-file.py"]
66+
)
67+
async with stdio_client(server_params) as (read_stream, write_stream):
68+
async with ClientSession(read_stream, write_stream) as session:
69+
# The session should raise an error when the connection closes
70+
with pytest.raises(McpError) as exc_info:
71+
await session.initialize()
72+
73+
# Check that we got a connection closed error
74+
assert exc_info.value.error.code == CONNECTION_CLOSED
75+
assert "Connection closed" in exc_info.value.error.message
76+
77+
78+
@pytest.mark.anyio
79+
async def test_stdio_client_nonexistent_command():
80+
"""Test that stdio_client raises an error for non-existent commands."""
81+
# Create a server with a non-existent command
82+
server_params = StdioServerParameters(
83+
command="/path/to/nonexistent/command",
84+
args=["--help"],
85+
)
86+
87+
# Should raise an error when trying to start the process
88+
with pytest.raises(Exception) as exc_info:
89+
async with stdio_client(server_params) as (_, _):
90+
pass
91+
92+
# The error should indicate the command was not found
93+
error_message = str(exc_info.value)
94+
assert (
95+
"nonexistent" in error_message
96+
or "not found" in error_message.lower()
97+
or "cannot find the file" in error_message.lower() # Windows error message
98+
)

0 commit comments

Comments
 (0)