Repository navigation
agents: failed MCP initialization leaves the stdio server running #524
Description
MCPConnection.__aenter__() enters the transport and ClientSession before calling initialize(), but it does not unwind either context if initialization raises. setup_mcp_connections() catches the error and continues, while the caller's AsyncExitStack has no cleanup callback because enter_async_context(connection) never completed.
Reproduced on current main, 930ad10bb2e21a9ef97fe1a88d2037e18a24465c, with Python 3.12.15, MCP 2.3.0, and Anthropic 2.0.0 on macOS. The local stdio server below rejects initialization, then stays alive after the outer stack exits. This needs no API key, external server, or network access. Run from the repository root with the documented agents dependencies installed (PYTHONPATH=. python /tmp/repro.py):
import asyncio
import os
from contextlib import AsyncExitStack
from pathlib import Path
import sys
import tempfile
from unittest.mock import patch
from agents.utils import connections
SERVER = '''import json, os, pathlib, sys
pathlib.Path(sys.argv[1]).write_text(str(os.getpid()))
for line in sys.stdin:
message = json.loads(line)
if message.get("method") == "initialize":
print(json.dumps({"jsonrpc": "2.0", "id": message["id"], "error": {"code": -32603, "message": "initialization refused"}}), flush=True)
'''
def alive(pid):
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
return True
async def main():
with tempfile.TemporaryDirectory(dir="/tmp", prefix="contrib-mcp-") as directory:
server = Path(directory) / "server.py"
pid_file = Path(directory) / "pid"
server.write_text(SERVER)
config = {"type": "stdio", "command": sys.executable, "args": [str(server), str(pid_file)]}
connection = connections.create_mcp_connection(config)
try:
with patch.object(connections, "create_mcp_connection", return_value=connection):
async with AsyncExitStack() as stack:
tools = await connections.setup_mcp_connections([config], stack)
print("loaded tools:", len(tools))
print("registered cleanup callbacks:", len(stack._exit_callbacks))
pid = int(pid_file.read_text())
print("child alive after outer stack exit:", alive(pid))
assert not alive(pid), "failed initialization left the stdio server running"
finally:
await connection.__aexit__(None, None, None)
if pid_file.exists():
print("child alive after explicit cleanup:", alive(int(pid_file.read_text())))
asyncio.run(main())Output before the expected assertion failure:
Loaded 0 MCP tools from 1 servers.
loaded tools: 0
registered cleanup callbacks: 0
child alive after outer stack exit: True
child alive after explicit cleanup: False
AssertionError: failed initialization left the stdio server running
The factory patch only retains the real connection for the final explicit cleanup; the transport, child process, protocol exchange, and SDK session are real. The expected behavior is to close the partially entered session and transport before re-raising the initialization error, so a failed server does not remain running for the rest of the agent run. No fix is included.
This concerns the existing stdio connection path in agents/utils/connections.py. The Streamable HTTP addition in #510 adds failure cleanup to its new subclass but leaves this base implementation unchanged.
Activity