Skip to content

Commit c9bb431

Browse files
committed
Add reproduction tests for issue #262: MCP Client Tool Call Hang
This adds test cases and a standalone reproduction script for issue #262 where session.call_tool() hangs while session.list_tools() works. The tests cover several potential causes: - Stdout buffering issues - Race conditions in async message handling - 0-capacity streams requiring strict handshaking - Interleaved notifications during tool execution - Bidirectional communication (sampling during tool execution) While these tests pass in the test environment, the issue may be: - Environment-specific (WSL vs Windows) - Already fixed in recent versions - Dependent on specific server implementations The standalone script allows users to test on their system to help identify environment-specific factors. Github-Issue: #262
1 parent 0dedbd9 commit c9bb431

File tree

2 files changed

+1486
-0
lines changed

2 files changed

+1486
-0
lines changed
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Standalone reproduction script for issue #262: MCP Client Tool Call Hang
4+
5+
This script attempts to reproduce the issue where:
6+
- await session.list_tools() works
7+
- await session.call_tool() hangs indefinitely
8+
9+
Usage:
10+
python reproduce_262_standalone.py [--server-only] [--client-only PORT]
11+
12+
The script can run in three modes:
13+
1. Full mode (default): Starts server and client in one process
14+
2. Server mode: Just run the server for external client testing
15+
3. Client mode: Connect to an existing server
16+
17+
Key observations from the original issue:
18+
- Debugger stepping makes the issue disappear (timing-sensitive)
19+
- Works on native Windows, fails on WSL Ubuntu
20+
- Both stdio and SSE transports affected
21+
22+
See: https://github.com/modelcontextprotocol/python-sdk/issues/262
23+
"""
24+
25+
import argparse
26+
import asyncio
27+
import sys
28+
import textwrap
29+
30+
# Check if MCP is available
31+
try:
32+
import mcp.types as types
33+
from mcp import ClientSession, StdioServerParameters
34+
from mcp.client.stdio import stdio_client
35+
except ImportError:
36+
print("ERROR: MCP SDK not installed. Run: pip install mcp")
37+
sys.exit(1)
38+
39+
40+
# Server script that mimics a real MCP server
41+
SERVER_SCRIPT = textwrap.dedent('''
42+
import json
43+
import sys
44+
import time
45+
46+
def send_response(response):
47+
"""Send a JSON-RPC response to stdout."""
48+
print(json.dumps(response), flush=True)
49+
50+
def read_request():
51+
"""Read a JSON-RPC request from stdin."""
52+
line = sys.stdin.readline()
53+
if not line:
54+
return None
55+
return json.loads(line)
56+
57+
def main():
58+
print("Server started", file=sys.stderr, flush=True)
59+
60+
while True:
61+
request = read_request()
62+
if request is None:
63+
print("Server: stdin closed, exiting", file=sys.stderr, flush=True)
64+
break
65+
66+
method = request.get("method", "")
67+
request_id = request.get("id")
68+
print(f"Server received: {method}", file=sys.stderr, flush=True)
69+
70+
if method == "initialize":
71+
send_response({
72+
"jsonrpc": "2.0",
73+
"id": request_id,
74+
"result": {
75+
"protocolVersion": "2024-11-05",
76+
"capabilities": {"tools": {}},
77+
"serverInfo": {"name": "test-server", "version": "1.0"}
78+
}
79+
})
80+
elif method == "notifications/initialized":
81+
print("Server: Initialized notification received", file=sys.stderr, flush=True)
82+
elif method == "tools/list":
83+
send_response({
84+
"jsonrpc": "2.0",
85+
"id": request_id,
86+
"result": {
87+
"tools": [{
88+
"name": "query-api-infos",
89+
"description": "Query API information",
90+
"inputSchema": {
91+
"type": "object",
92+
"properties": {
93+
"api_info_id": {"type": "string"}
94+
}
95+
}
96+
}]
97+
}
98+
})
99+
print("Server: Sent tools list", file=sys.stderr, flush=True)
100+
elif method == "tools/call":
101+
params = request.get("params", {})
102+
tool_name = params.get("name", "unknown")
103+
arguments = params.get("arguments", {})
104+
print(f"Server: Executing tool {tool_name} with args {arguments}", file=sys.stderr, flush=True)
105+
106+
# Simulate some processing time (like the original issue)
107+
time.sleep(0.1)
108+
109+
send_response({
110+
"jsonrpc": "2.0",
111+
"id": request_id,
112+
"result": {
113+
"content": [{"type": "text", "text": f"Result for {tool_name}"}],
114+
"isError": False
115+
}
116+
})
117+
print(f"Server: Sent tool result", file=sys.stderr, flush=True)
118+
elif method == "ping":
119+
send_response({
120+
"jsonrpc": "2.0",
121+
"id": request_id,
122+
"result": {}
123+
})
124+
else:
125+
print(f"Server: Unknown method {method}", file=sys.stderr, flush=True)
126+
send_response({
127+
"jsonrpc": "2.0",
128+
"id": request_id,
129+
"error": {"code": -32601, "message": f"Method not found: {method}"}
130+
})
131+
132+
if __name__ == "__main__":
133+
main()
134+
''').strip()
135+
136+
137+
async def handle_sampling_message(context, params: types.CreateMessageRequestParams):
138+
"""Sampling callback as shown in the original issue."""
139+
return types.CreateMessageResult(
140+
role="assistant",
141+
content=types.TextContent(type="text", text="Hello from model"),
142+
model="gpt-3.5-turbo",
143+
stopReason="endTurn",
144+
)
145+
146+
147+
async def run_test():
148+
"""Main test that reproduces the issue scenario."""
149+
print("=" * 60)
150+
print("Issue #262 Reproduction Test")
151+
print("=" * 60)
152+
print()
153+
154+
server_params = StdioServerParameters(
155+
command=sys.executable,
156+
args=["-c", SERVER_SCRIPT],
157+
env=None,
158+
)
159+
160+
print(f"Starting server with: {sys.executable}")
161+
print()
162+
163+
try:
164+
async with stdio_client(server_params) as (read, write):
165+
print("Connected to server")
166+
167+
async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session:
168+
print("Session created")
169+
170+
# Initialize
171+
print("\n1. Initializing session...")
172+
result = await session.initialize()
173+
print(f" Initialized with protocol version: {result.protocolVersion}")
174+
print(f" Server: {result.serverInfo.name} v{result.serverInfo.version}")
175+
176+
# List tools - this should work
177+
print("\n2. Listing tools...")
178+
tools = await session.list_tools()
179+
print(f" Found {len(tools.tools)} tool(s):")
180+
for tool in tools.tools:
181+
print(f" - {tool.name}: {tool.description}")
182+
183+
# Call tool - this is where the hang was reported
184+
print("\n3. Calling tool (this is where issue #262 hangs)...")
185+
print(" If this hangs, the issue is reproduced!")
186+
print(" Waiting...")
187+
188+
# Use a timeout to detect the hang
189+
try:
190+
import anyio
191+
192+
with anyio.fail_after(10):
193+
result = await session.call_tool("query-api-infos", arguments={"api_info_id": "8768555"})
194+
print(f" Tool result: {result.content[0].text}")
195+
print("\n" + "=" * 60)
196+
print("SUCCESS: Tool call completed - issue NOT reproduced")
197+
print("=" * 60)
198+
except TimeoutError:
199+
print("\n" + "=" * 60)
200+
print("TIMEOUT: Tool call hung - issue IS reproduced!")
201+
print("=" * 60)
202+
return False
203+
204+
print("\n4. Session closed cleanly")
205+
return True
206+
207+
except Exception as e:
208+
print(f"\nERROR: {type(e).__name__}: {e}")
209+
import traceback
210+
211+
traceback.print_exc()
212+
return False
213+
214+
215+
async def run_multiple_iterations(n: int = 10):
216+
"""Run the test multiple times to catch intermittent issues."""
217+
print(f"\nRunning {n} iterations to catch intermittent issues...")
218+
print()
219+
220+
successes = 0
221+
failures = 0
222+
223+
for i in range(n):
224+
print(f"\n{'=' * 60}")
225+
print(f"Iteration {i + 1}/{n}")
226+
print(f"{'=' * 60}")
227+
228+
try:
229+
success = await run_test()
230+
if success:
231+
successes += 1
232+
else:
233+
failures += 1
234+
except Exception as e:
235+
print(f"Exception: {e}")
236+
failures += 1
237+
238+
print(f"\n{'=' * 60}")
239+
print(f"RESULTS: {successes} successes, {failures} failures")
240+
print(f"{'=' * 60}")
241+
242+
if failures > 0:
243+
print("\nIssue #262 WAS reproduced in some iterations!")
244+
else:
245+
print("\nIssue #262 was NOT reproduced in any iteration.")
246+
247+
248+
def main():
249+
parser = argparse.ArgumentParser(description="Reproduce issue #262")
250+
parser.add_argument("--iterations", "-n", type=int, default=1, help="Number of test iterations (default: 1)")
251+
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output")
252+
253+
args = parser.parse_args()
254+
255+
print(f"Python version: {sys.version}")
256+
print(f"Platform: {sys.platform}")
257+
print()
258+
259+
if args.iterations > 1:
260+
asyncio.run(run_multiple_iterations(args.iterations))
261+
else:
262+
asyncio.run(run_test())
263+
264+
265+
if __name__ == "__main__":
266+
main()

0 commit comments

Comments
 (0)