#2479·mcp-use

client: StdioConnector retry on failed handshake can spawn child before predecessor process exits

Author: rohith500Created Sep 8, 2026Updated Sep 8, 2026
Labelsbuginvalidserverclientprotocolmcp-use/mcp-use

Summary

In @mcp-use/client, when StdioConnector.connect() fails during initialization (e.g. handshake protocol rejection, invalid server capabilities, or injected error) or when the connector is closed and retried, cleanupResources() / closeConnection() invokes await transport.close().

However, the underlying @modelcontextprotocol/[email protected] StdioClientTransport.close() clears this._process = null synchronously and does not return a promise that joins on the child process's 'exit' or 'close' event. As a consequence, closeConnection() resolves while the OS child process is still active and in the middle of shutting down.

If the caller retries connection on the same connector, a new child process is spawned while the predecessor child process is still running.

Reproduction

As characterized by @Elioooon in https://github.com/mcp-use/mcp-use/pull/2458#issuecomment-5587664551: When an initialization failure occurs, Client._legacyHandshake() invokes this.close() without awaiting it, and StdioClientTransport.close() nullifies its process handle without waiting for the child to exit:

javascript
import assert from "node:assert/strict";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";

// Child server that fails initialize
const transport = new StdioClientTransport({
  command: process.execPath,
  args: ["-e", `/* keep open until SIGUSR2 */`],
});

await assert.rejects(client.connect(transport), /initialization failure/);
await client.close();

// Child process is still alive when client.close() resolves:
process.kill(transportPid, 0); // succeeds (ESRCH is not thrown)

Production Impact

  1. Exclusive Lock Contention: If an MCP server acquires an exclusive lock on startup (such as an embedded SQLite / DuckDB database lock, a file lock in .mcp/, or leveldb), an immediate retry can fail with EBUSY / EWOULDBLOCK because the exiting predecessor process hasn't finished flushing and releasing the OS lock.
  2. Port / Socket Collisions: If a local stdio server binds to a fixed loopback port or IPC socket for auxiliary services, rapid restart collides with EADDRINUSE.
  3. Resource Spikes: Transient CPU/memory spikes from multiple concurrent instances of the same server running simultaneously during crash-restart loops.

Proposed Solution

While this should also be addressed upstream in @modelcontextprotocol/client (by having StdioClientTransport.close() await the child process 'exit' event), mcp-use can provide a robust framework-level guarantee in StdioConnectionManager:

  1. In StdioConnectionManager, retain a reference to the spawned child process (e.g. from transport.process or transport.pid).
  2. In closeConnection(), after awaiting transport.close(), add an explicit process exit join barrier:
typescript
if (child && !child.killed && child.exitCode === null) {
  await Promise.race([
    once(child, "exit"),
    new Promise((resolve) => setTimeout(resolve, 2000)), // Grace period before SIGKILL fallback
  ]);
}

This guarantees that when disconnect() or failed-handshake teardown completes, the predecessor OS process has actually terminated before any subsequent connect() attempt proceeds.