Skip to content

agents: failed MCP initialization leaves the stdio server running #524

Open

Description

@BichengWang

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions