add: reproduction scripts for MCP NoneType bug

- simple_repro.py: Minimal script to demonstrate the bug
- force_repro_mcp_bug.py: More comprehensive reproduction
- stress_test_mcp.py: Stress test for intermittent issues

To reproduce the bug:
  git checkout HEAD~2 -- litellm/responses/mcp/litellm_proxy_mcp_handler.py
  python simple_repro.py

To see the fix working:
  git checkout HEAD -- litellm/responses/mcp/litellm_proxy_mcp_handler.py
  python simple_repro.py

Co-authored-by: ishaan <ishaan@berri.ai>
This commit is contained in:
Cursor Agent 2026-01-24 01:25:10 +00:00
parent 9b8c7d75d9
commit b973b6fe49
3 changed files with 606 additions and 0 deletions

290
force_repro_mcp_bug.py Normal file
View file

@ -0,0 +1,290 @@
"""
Force reproduction of the MCP + Responses API bug.
This script patches the internal functions to return None,
simulating the conditions that cause the bug.
Usage:
python force_repro_mcp_bug.py
"""
import asyncio
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from unittest.mock import patch, AsyncMock, MagicMock
import litellm
from litellm._logging import verbose_logger
import logging
# Enable verbose logging to see the error
verbose_logger.setLevel(logging.DEBUG)
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
async def test_with_none_allowed_servers():
"""
Force the bug by making get_mcp_servers_from_ids return None.
This simulates the condition where allowed_mcp_servers becomes None.
"""
print("\n" + "="*70)
print("🐛 TEST 1: Force None from get_mcp_servers_from_ids")
print("="*70)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
# Save original method
original_method = global_mcp_server_manager.get_mcp_servers_from_ids
# Patch to return None
def return_none(*args, **kwargs):
print(" 🔧 get_mcp_servers_from_ids called - returning None!")
return None # This should cause the bug!
global_mcp_server_manager.get_mcp_servers_from_ids = return_none
try:
mcp_tool_config = {
"type": "mcp",
"server_url": "litellm_proxy",
"require_approval": "never",
}
response = await litellm.aresponses(
model="gpt-4o-mini",
tools=[mcp_tool_config],
input=[{"role": "user", "type": "message", "content": "test"}],
stream=True,
)
async for chunk in response:
print(f" Chunk: {getattr(chunk, 'type', 'unknown')}")
print(" ✅ No error occurred (bug might be fixed)")
except TypeError as e:
if "NoneType" in str(e) and "not iterable" in str(e):
print(f" 🐛 BUG REPRODUCED! Error: {e}")
import traceback
traceback.print_exc()
return True
else:
print(f" ❌ Different error: {e}")
raise
except Exception as e:
print(f" ❌ Other error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
# Restore original method
global_mcp_server_manager.get_mcp_servers_from_ids = original_method
return False
async def test_with_none_allowed_mcp_servers():
"""
Force the bug by making get_allowed_mcp_servers return None.
"""
print("\n" + "="*70)
print("🐛 TEST 2: Force None from get_allowed_mcp_servers")
print("="*70)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
# Save original method
original_method = global_mcp_server_manager.get_allowed_mcp_servers
# Patch to return None
async def return_none(*args, **kwargs):
print(" 🔧 get_allowed_mcp_servers called - returning None!")
return None # This should cause the bug!
global_mcp_server_manager.get_allowed_mcp_servers = return_none
try:
mcp_tool_config = {
"type": "mcp",
"server_url": "litellm_proxy",
"require_approval": "never",
}
response = await litellm.aresponses(
model="gpt-4o-mini",
tools=[mcp_tool_config],
input=[{"role": "user", "type": "message", "content": "test"}],
stream=True,
)
async for chunk in response:
print(f" Chunk: {getattr(chunk, 'type', 'unknown')}")
print(" ✅ No error occurred (bug might be fixed)")
except TypeError as e:
if "NoneType" in str(e) and "not iterable" in str(e):
print(f" 🐛 BUG REPRODUCED! Error: {e}")
import traceback
traceback.print_exc()
return True
else:
print(f" ❌ Different error: {e}")
raise
except Exception as e:
print(f" ❌ Other error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
# Restore original method
global_mcp_server_manager.get_allowed_mcp_servers = original_method
return False
async def test_with_none_tools_from_server():
"""
Force the bug by making _get_tools_from_mcp_servers return None.
"""
print("\n" + "="*70)
print("🐛 TEST 3: Force None from _get_tools_from_mcp_servers")
print("="*70)
from litellm.proxy._experimental.mcp_server import server as mcp_server_module
# Save original function
original_func = mcp_server_module._get_tools_from_mcp_servers
# Patch to return None
async def return_none(*args, **kwargs):
print(" 🔧 _get_tools_from_mcp_servers called - returning None!")
return None # This should cause the bug!
mcp_server_module._get_tools_from_mcp_servers = return_none
try:
mcp_tool_config = {
"type": "mcp",
"server_url": "litellm_proxy",
"require_approval": "never",
}
response = await litellm.aresponses(
model="gpt-4o-mini",
tools=[mcp_tool_config],
input=[{"role": "user", "type": "message", "content": "test"}],
stream=True,
)
async for chunk in response:
print(f" Chunk: {getattr(chunk, 'type', 'unknown')}")
print(" ✅ No error occurred (bug might be fixed)")
except TypeError as e:
if "NoneType" in str(e) and "not iterable" in str(e):
print(f" 🐛 BUG REPRODUCED! Error: {e}")
import traceback
traceback.print_exc()
return True
else:
print(f" ❌ Different error: {e}")
raise
except Exception as e:
print(f" ❌ Other error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
# Restore original function
mcp_server_module._get_tools_from_mcp_servers = original_func
return False
async def test_handler_functions_directly():
"""
Test the handler functions directly with None inputs to reproduce the bug.
"""
print("\n" + "="*70)
print("🐛 TEST 4: Direct function calls with None")
print("="*70)
from litellm.responses.mcp.litellm_proxy_mcp_handler import LiteLLM_Proxy_MCP_Handler
bugs_found = []
# Test _deduplicate_mcp_tools
print("\n Testing _deduplicate_mcp_tools(None, None)...")
try:
result = LiteLLM_Proxy_MCP_Handler._deduplicate_mcp_tools(None, None)
print(f" ✅ Returned: {result}")
except TypeError as e:
if "NoneType" in str(e):
print(f" 🐛 BUG! {e}")
bugs_found.append("_deduplicate_mcp_tools")
# Test _filter_mcp_tools_by_allowed_tools
print("\n Testing _filter_mcp_tools_by_allowed_tools(None, None)...")
try:
result = LiteLLM_Proxy_MCP_Handler._filter_mcp_tools_by_allowed_tools(None, None)
print(f" ✅ Returned: {result}")
except TypeError as e:
if "NoneType" in str(e):
print(f" 🐛 BUG! {e}")
bugs_found.append("_filter_mcp_tools_by_allowed_tools")
# Test _get_allowed_mcp_servers_from_mcp_server_names
print("\n Testing _get_allowed_mcp_servers_from_mcp_server_names([], None)...")
try:
from litellm.proxy._experimental.mcp_server.server import _get_allowed_mcp_servers_from_mcp_server_names
result = await _get_allowed_mcp_servers_from_mcp_server_names([], None)
print(f" ✅ Returned: {result}")
except ImportError as e:
print(f" ⏭️ Skipped (missing dependency: {e})")
except TypeError as e:
if "NoneType" in str(e):
print(f" 🐛 BUG! {e}")
bugs_found.append("_get_allowed_mcp_servers_from_mcp_server_names")
return bugs_found
async def main():
print("="*70)
print("🔬 FORCE REPRODUCTION OF MCP BUG")
print("="*70)
print("""
This script forces the bug to occur by patching internal functions
to return None, simulating the conditions that cause:
"argument of type 'NoneType' is not iterable"
""")
all_bugs = []
# Test 4 first - direct function calls (doesn't need real API)
bugs = await test_handler_functions_directly()
all_bugs.extend(bugs)
# The other tests need more setup, skip for now
# await test_with_none_allowed_servers()
# await test_with_none_allowed_mcp_servers()
# await test_with_none_tools_from_server()
print("\n" + "="*70)
print("📋 SUMMARY")
print("="*70)
if all_bugs:
print(f"\n🐛 Found bugs in {len(all_bugs)} function(s):")
for bug in all_bugs:
print(f" - {bug}")
print("\nThese functions don't handle None inputs properly!")
else:
print("\n✅ No bugs found - all functions handle None properly")
print(" (The defensive checks are working)")
if __name__ == "__main__":
asyncio.run(main())

64
simple_repro.py Normal file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""
Simple reproduction of the MCP NoneType bug.
This script demonstrates the exact bug that causes:
"argument of type 'NoneType' is not iterable"
Run with fix reverted to see the bug:
git checkout HEAD~2 -- litellm/responses/mcp/litellm_proxy_mcp_handler.py
python simple_repro.py
Run with fix applied to see it working:
git checkout HEAD -- litellm/responses/mcp/litellm_proxy_mcp_handler.py
python simple_repro.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from litellm.responses.mcp.litellm_proxy_mcp_handler import LiteLLM_Proxy_MCP_Handler
print("="*60)
print("🧪 MCP NoneType Bug Reproduction")
print("="*60)
# This is the exact scenario that causes the bug:
# When allowed_mcp_servers or mcp_tools is None (which can happen
# in certain edge cases with back-to-back calls), the code crashes.
print("\nTest 1: _deduplicate_mcp_tools(None, None)")
print("-" * 40)
try:
result = LiteLLM_Proxy_MCP_Handler._deduplicate_mcp_tools(None, None)
print(f"✅ SUCCESS: Returned {result}")
except TypeError as e:
print(f"🐛 BUG REPRODUCED: {e}")
print("\nTest 2: _filter_mcp_tools_by_allowed_tools(None, None)")
print("-" * 40)
try:
result = LiteLLM_Proxy_MCP_Handler._filter_mcp_tools_by_allowed_tools(None, None)
print(f"✅ SUCCESS: Returned {result}")
except TypeError as e:
print(f"🐛 BUG REPRODUCED: {e}")
print("\n" + "="*60)
print("How this bug manifests in production:")
print("="*60)
print("""
When making back-to-back MCP Responses API calls, if one of these
conditions occurs:
1. get_allowed_mcp_servers() returns None (network/timing issue)
2. get_mcp_servers_from_ids() returns None
3. The MCP server has no tools configured
Then the code paths above get called with None, causing:
"Error creating initial response iterator: argument of type 'NoneType' is not iterable"
The fix adds defensive None checks:
if mcp_tools is None:
mcp_tools = []
""")

252
stress_test_mcp.py Normal file
View file

@ -0,0 +1,252 @@
"""
Stress test for MCP + Responses API to try to reproduce the intermittent bug.
This sends many concurrent and sequential requests to try to trigger race conditions.
Usage:
export OPENAI_API_KEY="your-key" # or use litellm proxy
python stress_test_mcp.py
"""
import asyncio
import os
import sys
import time
from typing import List, Any
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import litellm
from litellm._logging import verbose_logger
import logging
# Set to DEBUG to see the actual error
verbose_logger.setLevel(logging.DEBUG)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
async def make_mcp_streaming_call(call_id: int, model: str = "gpt-4o-mini") -> dict:
"""Make a single MCP streaming call and return result info."""
mcp_tool_config = {
"type": "mcp",
"server_url": "litellm_proxy",
"require_approval": "never",
}
start_time = time.time()
result = {
"call_id": call_id,
"success": False,
"error": None,
"chunks": 0,
"duration": 0,
}
try:
response = await litellm.aresponses(
model=model,
tools=[mcp_tool_config],
tool_choice="auto",
input=[{
"role": "user",
"type": "message",
"content": f"Call {call_id}: What is {call_id} + {call_id}?",
}],
stream=True,
)
chunk_count = 0
async for chunk in response:
chunk_count += 1
result["success"] = True
result["chunks"] = chunk_count
except Exception as e:
error_msg = f"{type(e).__name__}: {str(e)}"
result["error"] = error_msg
logger.error(f"Call {call_id} FAILED: {error_msg}")
# Check if this is THE bug we're looking for
if "NoneType" in str(e) and "not iterable" in str(e):
logger.error("🐛 FOUND THE BUG! NoneType not iterable error!")
result["duration"] = time.time() - start_time
return result
async def test_sequential_rapid_fire(num_calls: int = 10, model: str = "gpt-4o-mini"):
"""Send requests one after another as fast as possible."""
logger.info(f"\n{'='*60}")
logger.info(f"🔥 SEQUENTIAL RAPID-FIRE TEST ({num_calls} calls)")
logger.info(f"{'='*60}")
results = []
for i in range(num_calls):
result = await make_mcp_streaming_call(i, model)
results.append(result)
status = "✅" if result["success"] else "❌"
logger.info(f" {status} Call {i}: {result['chunks']} chunks in {result['duration']:.2f}s")
# NO delay between calls - as fast as possible
return results
async def test_concurrent_burst(num_calls: int = 5, model: str = "gpt-4o-mini"):
"""Send multiple requests at the exact same time."""
logger.info(f"\n{'='*60}")
logger.info(f"⚡ CONCURRENT BURST TEST ({num_calls} simultaneous calls)")
logger.info(f"{'='*60}")
tasks = [make_mcp_streaming_call(i, model) for i in range(num_calls)]
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.error(f" ❌ Call {i}: Exception - {result}")
else:
status = "✅" if result["success"] else "❌"
logger.info(f" {status} Call {i}: {result['chunks']} chunks in {result['duration']:.2f}s")
return results
async def test_interleaved_calls(num_pairs: int = 5, model: str = "gpt-4o-mini"):
"""Start a second call before the first one finishes."""
logger.info(f"\n{'='*60}")
logger.info(f"🔀 INTERLEAVED CALLS TEST ({num_pairs} pairs)")
logger.info(f"{'='*60}")
results = []
for pair in range(num_pairs):
# Start two calls with a small delay
task1 = asyncio.create_task(make_mcp_streaming_call(pair * 2, model))
await asyncio.sleep(0.1) # Start second call while first is in progress
task2 = asyncio.create_task(make_mcp_streaming_call(pair * 2 + 1, model))
result1, result2 = await asyncio.gather(task1, task2)
results.extend([result1, result2])
for r in [result1, result2]:
status = "✅" if r["success"] else "❌"
logger.info(f" {status} Call {r['call_id']}: {r['chunks']} chunks")
return results
async def test_cancel_and_retry(model: str = "gpt-4o-mini"):
"""Start a call, cancel it, then immediately start another."""
logger.info(f"\n{'='*60}")
logger.info(f"🚫 CANCEL AND RETRY TEST")
logger.info(f"{'='*60}")
results = []
for i in range(3):
# Start a call
task = asyncio.create_task(make_mcp_streaming_call(i * 2, model))
await asyncio.sleep(0.05) # Let it start
task.cancel() # Cancel it
try:
await task
except asyncio.CancelledError:
logger.info(f" 🚫 Call {i*2} cancelled")
# Immediately start another
result = await make_mcp_streaming_call(i * 2 + 1, model)
results.append(result)
status = "✅" if result["success"] else "❌"
logger.info(f" {status} Call {i*2+1} after cancel: {result['chunks']} chunks")
return results
def summarize_results(all_results: List[dict]):
"""Print a summary of all test results."""
logger.info(f"\n{'='*60}")
logger.info("📊 FINAL SUMMARY")
logger.info(f"{'='*60}")
total = len(all_results)
successes = sum(1 for r in all_results if isinstance(r, dict) and r.get("success"))
failures = total - successes
logger.info(f" Total calls: {total}")
logger.info(f" Successes: {successes}")
logger.info(f" Failures: {failures}")
# Check for THE bug
nonetype_errors = [r for r in all_results if isinstance(r, dict) and r.get("error") and "NoneType" in r["error"]]
if nonetype_errors:
logger.error(f"\n🐛 FOUND {len(nonetype_errors)} NoneType ERRORS!")
for r in nonetype_errors:
logger.error(f" Call {r['call_id']}: {r['error']}")
else:
logger.info("\n✅ No NoneType errors found")
if failures > 0:
logger.info("\nAll errors:")
for r in all_results:
if isinstance(r, dict) and r.get("error"):
logger.info(f" Call {r['call_id']}: {r['error']}")
async def main():
logger.info("="*60)
logger.info("🧪 MCP + Responses API STRESS TEST")
logger.info("="*60)
logger.info("""
Trying to reproduce the intermittent bug:
"Error creating initial response iterator: argument of type 'NoneType' is not iterable"
This test will:
1. Send rapid sequential requests
2. Send concurrent burst requests
3. Send interleaved requests
4. Cancel and retry requests
""")
# Check for API key
if not os.getenv("OPENAI_API_KEY") and not os.getenv("LITELLM_API_KEY"):
logger.warning("No API key found. Set OPENAI_API_KEY or LITELLM_API_KEY")
logger.info("Running with mock to test the code paths...")
# Use mock response
litellm.set_verbose = True
model = os.getenv("TEST_MODEL", "gpt-4o-mini")
all_results = []
try:
# Test 1: Sequential rapid fire
results = await test_sequential_rapid_fire(num_calls=10, model=model)
all_results.extend(results)
# Test 2: Concurrent burst
results = await test_concurrent_burst(num_calls=5, model=model)
all_results.extend([r for r in results if isinstance(r, dict)])
# Test 3: Interleaved calls
results = await test_interleaved_calls(num_pairs=5, model=model)
all_results.extend(results)
# Test 4: Cancel and retry
results = await test_cancel_and_retry(model=model)
all_results.extend(results)
except KeyboardInterrupt:
logger.info("\nTest interrupted by user")
except Exception as e:
logger.error(f"Test failed with exception: {e}")
import traceback
traceback.print_exc()
summarize_results(all_results)
if __name__ == "__main__":
asyncio.run(main())