Skip to content

Commit f6bd35b

Browse files
committed
feat: add debug delays to reproduce issue #262 race condition
Add environment variable-gated delays in the library code that allow reliably reproducing the race condition causing call_tool() to hang: Library changes: - src/mcp/client/stdio/__init__.py: Add delay in stdin_writer before entering receive loop (MCP_DEBUG_RACE_DELAY_STDIO env var) - src/mcp/shared/session.py: Add delay in _receive_loop before entering receive loop (MCP_DEBUG_RACE_DELAY_SESSION env var) Usage: - Set env var to "forever" for guaranteed hang (demo purposes) - Set env var to a float (e.g., "0.5") for timed delay New files: - server_262.py: Minimal MCP server for reproduction - client_262.py: Client demonstrating the hang with documentation Run reproduction: MCP_DEBUG_RACE_DELAY_STDIO=forever python client_262.py Github-Issue: #262
1 parent f019021 commit f6bd35b

File tree

4 files changed

+294
-0
lines changed

4 files changed

+294
-0
lines changed

client_262.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Simple MCP client for reproducing issue #262.
4+
5+
This client connects to server_262.py and demonstrates the race condition
6+
that causes call_tool() to hang.
7+
8+
USAGE:
9+
10+
Normal run (should work):
11+
python client_262.py
12+
13+
Reproduce the bug with GUARANTEED hang (use 'forever'):
14+
MCP_DEBUG_RACE_DELAY_STDIO=forever python client_262.py
15+
16+
Or with a timed delay (may or may not hang depending on timing):
17+
MCP_DEBUG_RACE_DELAY_STDIO=0.5 python client_262.py
18+
19+
You can also delay the session receive loop:
20+
MCP_DEBUG_RACE_DELAY_SESSION=forever python client_262.py
21+
22+
Or both for maximum effect:
23+
MCP_DEBUG_RACE_DELAY_STDIO=forever MCP_DEBUG_RACE_DELAY_SESSION=forever python client_262.py
24+
25+
EXPLANATION:
26+
27+
The bug is caused by a race condition in the MCP client:
28+
29+
1. stdio_client creates zero-capacity memory streams (capacity=0)
30+
2. stdio_client starts stdin_writer task with start_soon() (not awaited)
31+
3. When client calls send_request(), it sends to the write_stream
32+
4. If stdin_writer hasn't reached its receive loop yet, send() blocks forever
33+
34+
The environment variables inject delays at the start of the background tasks,
35+
widening the race window to make the bug reliably reproducible.
36+
37+
See: https://github.com/modelcontextprotocol/python-sdk/issues/262
38+
"""
39+
40+
import os
41+
import sys
42+
43+
import anyio
44+
45+
from mcp import ClientSession, StdioServerParameters
46+
from mcp.client.stdio import stdio_client
47+
48+
49+
async def main() -> None:
50+
print("=" * 70)
51+
print("Issue #262 Reproduction Client")
52+
print("=" * 70)
53+
print()
54+
55+
# Check if debug delays are enabled
56+
stdio_delay = os.environ.get("MCP_DEBUG_RACE_DELAY_STDIO")
57+
session_delay = os.environ.get("MCP_DEBUG_RACE_DELAY_SESSION")
58+
59+
if stdio_delay or session_delay:
60+
print("DEBUG DELAYS ENABLED:")
61+
if stdio_delay:
62+
print(f" MCP_DEBUG_RACE_DELAY_STDIO = {stdio_delay}s")
63+
if session_delay:
64+
print(f" MCP_DEBUG_RACE_DELAY_SESSION = {session_delay}s")
65+
print()
66+
print("This should cause a hang/timeout due to the race condition!")
67+
print()
68+
else:
69+
print("No debug delays - this should work normally.")
70+
print()
71+
print("To reproduce the bug, run with:")
72+
print(" MCP_DEBUG_RACE_DELAY_STDIO=forever python client_262.py")
73+
print()
74+
75+
# Server parameters - run server_262.py
76+
script_dir = os.path.dirname(os.path.abspath(__file__))
77+
server_script = os.path.join(script_dir, "server_262.py")
78+
params = StdioServerParameters(
79+
command=sys.executable,
80+
args=["-u", server_script], # -u for unbuffered output
81+
)
82+
83+
timeout = 5.0 # 5 second timeout to detect hangs
84+
print(f"Connecting to server (timeout: {timeout}s)...")
85+
print()
86+
87+
try:
88+
with anyio.fail_after(timeout):
89+
async with stdio_client(params) as (read_stream, write_stream):
90+
print("[OK] Connected to server via stdio")
91+
92+
async with ClientSession(read_stream, write_stream) as session:
93+
print("[OK] ClientSession created")
94+
95+
# Initialize
96+
print("Calling session.initialize()...")
97+
init_result = await session.initialize()
98+
print(f"[OK] Initialized: {init_result.serverInfo.name}")
99+
100+
# List tools
101+
print("Calling session.list_tools()...")
102+
tools = await session.list_tools()
103+
print(f"[OK] Listed {len(tools.tools)} tools: {[t.name for t in tools.tools]}")
104+
105+
# Call tool - this is where issue #262 hangs!
106+
print("Calling session.call_tool('greet', {'name': 'Issue 262'})...")
107+
result = await session.call_tool("greet", arguments={"name": "Issue 262"})
108+
print(f"[OK] Tool result: {result.content[0].text}")
109+
110+
print()
111+
print("=" * 70)
112+
print("SUCCESS! All operations completed without hanging.")
113+
print("=" * 70)
114+
115+
except TimeoutError:
116+
print()
117+
print("=" * 70)
118+
print("TIMEOUT! The client hung - race condition reproduced!")
119+
print("=" * 70)
120+
print()
121+
print("This is issue #262: The race condition caused a deadlock.")
122+
print()
123+
print("Root cause:")
124+
print(" - Zero-capacity streams require sender and receiver to rendezvous")
125+
print(" - Background tasks (stdin_writer) are started with start_soon()")
126+
print(" - If send_request() runs before stdin_writer is ready, it blocks forever")
127+
print()
128+
print("The injected delays widen this race window to make it reproducible.")
129+
sys.exit(1)
130+
131+
except Exception as e:
132+
print()
133+
print(f"ERROR: {type(e).__name__}: {e}")
134+
import traceback
135+
136+
traceback.print_exc()
137+
sys.exit(1)
138+
139+
140+
if __name__ == "__main__":
141+
anyio.run(main)

server_262.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Simple MCP server for reproducing issue #262.
4+
5+
This is a minimal MCP server that:
6+
1. Handles initialize
7+
2. Exposes a simple tool
8+
3. Handles tool calls
9+
10+
Run: python server_262.py
11+
12+
See: https://github.com/modelcontextprotocol/python-sdk/issues/262
13+
"""
14+
15+
import json
16+
import sys
17+
18+
19+
def send_response(response: dict) -> None:
20+
"""Send a JSON-RPC response to stdout."""
21+
print(json.dumps(response), flush=True)
22+
23+
24+
def read_request() -> dict | None:
25+
"""Read a JSON-RPC request from stdin."""
26+
line = sys.stdin.readline()
27+
if not line:
28+
return None
29+
return json.loads(line)
30+
31+
32+
def main() -> None:
33+
"""Main server loop."""
34+
while True:
35+
request = read_request()
36+
if request is None:
37+
break
38+
39+
method = request.get("method", "")
40+
request_id = request.get("id")
41+
42+
if method == "initialize":
43+
send_response(
44+
{
45+
"jsonrpc": "2.0",
46+
"id": request_id,
47+
"result": {
48+
"protocolVersion": "2024-11-05",
49+
"capabilities": {"tools": {}},
50+
"serverInfo": {"name": "issue-262-server", "version": "1.0.0"},
51+
},
52+
}
53+
)
54+
55+
elif method == "notifications/initialized":
56+
# Notification, no response needed
57+
pass
58+
59+
elif method == "tools/list":
60+
send_response(
61+
{
62+
"jsonrpc": "2.0",
63+
"id": request_id,
64+
"result": {
65+
"tools": [
66+
{
67+
"name": "greet",
68+
"description": "A simple greeting tool",
69+
"inputSchema": {
70+
"type": "object",
71+
"properties": {"name": {"type": "string", "description": "Name to greet"}},
72+
"required": ["name"],
73+
},
74+
}
75+
]
76+
},
77+
}
78+
)
79+
80+
elif method == "tools/call":
81+
tool_name = request.get("params", {}).get("name", "")
82+
arguments = request.get("params", {}).get("arguments", {})
83+
84+
if tool_name == "greet":
85+
name = arguments.get("name", "World")
86+
send_response(
87+
{
88+
"jsonrpc": "2.0",
89+
"id": request_id,
90+
"result": {"content": [{"type": "text", "text": f"Hello, {name}!"}], "isError": False},
91+
}
92+
)
93+
else:
94+
send_response(
95+
{
96+
"jsonrpc": "2.0",
97+
"id": request_id,
98+
"error": {"code": -32601, "message": f"Unknown tool: {tool_name}"},
99+
}
100+
)
101+
102+
elif method == "ping":
103+
send_response({"jsonrpc": "2.0", "id": request_id, "result": {}})
104+
105+
# Unknown method - send error for requests (have id), ignore notifications
106+
elif request_id is not None:
107+
send_response(
108+
{
109+
"jsonrpc": "2.0",
110+
"id": request_id,
111+
"error": {"code": -32601, "message": f"Method not found: {method}"},
112+
}
113+
)
114+
115+
116+
if __name__ == "__main__":
117+
main()

src/mcp/client/stdio/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,23 @@ async def stdout_reader():
166166
async def stdin_writer():
167167
assert process.stdin, "Opened process is missing stdin"
168168

169+
# DEBUG: Inject delay to reproduce issue #262 race condition
170+
# This prevents stdin_writer from entering its receive loop, simulating
171+
# the scenario where the task isn't ready when send_request() is called.
172+
# Set MCP_DEBUG_RACE_DELAY_STDIO=<seconds> to enable (e.g., "0.1").
173+
# Set MCP_DEBUG_RACE_DELAY_STDIO=forever to wait indefinitely (guaranteed hang).
174+
_race_delay = os.environ.get("MCP_DEBUG_RACE_DELAY_STDIO")
175+
if _race_delay:
176+
if _race_delay.lower() == "forever":
177+
# Wait forever - guarantees the race condition manifests
178+
never_ready = anyio.Event()
179+
await never_ready.wait()
180+
else:
181+
# Wait for specified duration - creates a race window
182+
# During this time, stdin_writer isn't ready to receive,
183+
# so any send() to write_stream will block
184+
await anyio.sleep(float(_race_delay))
185+
169186
try:
170187
async with write_stream_reader:
171188
async for session_message in write_stream_reader:
@@ -185,6 +202,7 @@ async def stdin_writer():
185202
):
186203
tg.start_soon(stdout_reader)
187204
tg.start_soon(stdin_writer)
205+
188206
try:
189207
yield read_stream, write_stream
190208
finally:

src/mcp/shared/session.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,24 @@ async def _send_response(self, request_id: RequestId, response: SendResultT | Er
349349
await self._write_stream.send(session_message)
350350

351351
async def _receive_loop(self) -> None:
352+
# DEBUG: Inject delay to reproduce issue #262 race condition
353+
# This prevents _receive_loop from entering its receive loop, simulating
354+
# the scenario where the task isn't ready when responses arrive.
355+
# Set MCP_DEBUG_RACE_DELAY_SESSION=<seconds> to enable (e.g., "0.1").
356+
# Set MCP_DEBUG_RACE_DELAY_SESSION=forever to wait indefinitely (guaranteed hang).
357+
import os
358+
359+
_race_delay = os.environ.get("MCP_DEBUG_RACE_DELAY_SESSION")
360+
if _race_delay:
361+
if _race_delay.lower() == "forever":
362+
# Wait forever - guarantees responses are never processed
363+
never_ready = anyio.Event()
364+
await never_ready.wait()
365+
else:
366+
# Wait for specified duration - creates a window where responses
367+
# might not be processed in time
368+
await anyio.sleep(float(_race_delay))
369+
352370
async with (
353371
self._read_stream,
354372
self._write_stream,

0 commit comments

Comments
 (0)