client: StdioConnector retry on failed handshake can spawn child before predecessor process exits
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:
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
- 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 withEBUSY/EWOULDBLOCKbecause the exiting predecessor process hasn't finished flushing and releasing the OS lock. - 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. - 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:
- In
StdioConnectionManager, retain a reference to the spawned child process (e.g. fromtransport.processortransport.pid). - In
closeConnection(), after awaitingtransport.close(), add an explicit process exit join barrier:
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.
Source: mcp-use/mcp-use