MCP stdio servers reported as 'failed' on exit despite clean shutdown

Resolved 💬 5 comments Opened Mar 6, 2026 by arcivanov Closed Apr 4, 2026

Summary

Claude Code 2.1.70 reports "N MCP servers failed" in the session summary on /exit, even though all MCP servers exit cleanly with code 0 within milliseconds of receiving SIGINT. N equals the number of configured stdio MCP servers.

Reproduction

  1. Configure one or more stdio MCP servers (tested with two Python/FastMCP servers)
  2. Start a Claude Code session
  3. Use at least one MCP tool
  4. /exit
  5. Session summary line shows "N MCP servers failed" (where N = number of configured servers)

This is 100% reproducible.

Root Cause (from reverse-engineered JS bundle)

We extracted the JS bundle from the Claude Code 2.1.70 Node.js SEA binary and traced the issue to the onclose handler for MCP clients.

The onclose handler unconditionally sets type:"failed" for stdio

When an MCP client's transport closes, the onclose handler checks the transport type. For non-stdio transports (SSE, etc.), it attempts automatic reconnection. For stdio transports, it unconditionally sets type:"failed" with no check for whether the close was initiated by Claude Code's own shutdown sequence:

E.client.onclose = () => {
    let T = E.config.type ?? "stdio";
    // ... cache invalidation ...
    if (T !== "stdio" && T !== "sdk") {
        // Non-stdio: attempt reconnection with exponential backoff
        // ...
    } else {
        P({...E, type: "failed"})  // Unconditionally "failed" for stdio!
    }
}

The shutdown sequence kills the server, then closes the client

The shutdown code sends SIGINT (then SIGTERM after 100ms, then SIGKILL after 400ms more) to kill the server process. After the process dies, it calls await q.close() on the MCP client:

// 1. Kill the server process
process.kill(U, "SIGINT");
// ... poll every 50ms, escalate to SIGTERM/SIGKILL ...

// 2. THEN close the client (server is already dead)
try {
    await q.close()
} catch (x) {
    LA(H, `Error closing client: ${x}`)
}

When the server dies from SIGINT, the stdio transport sees EOF/broken pipe and fires onclose, which sets type:"failed" — even though Claude Code itself just killed the server.

The UI then displays the stale "failed" status

The notification system filters MCP clients with type === "failed" and displays "N MCP server(s) failed" in the session summary:

let M = L.filter(xZM);  // xZM = (H) => H.type === "failed" && ...
if (M.length > 0) {
    D({key: "mcp-failed", jsx: /* "N MCP servers failed" */ });
}

Trace evidence

We instrumented the MCP servers with a stdio proxy that logs all I/O, signals, and exit codes. The servers handle SIGINT and exit with code 0 in ~5ms:

17:05:42.786689 SIGNAL SIGINT -> forwarded to child
17:05:42.791614 STDOUT EOF from child
17:05:42.791663 child exited code=0

The server exits cleanly. The process termination cleanup code even logs "MCP server process exited cleanly". But onclose has already set type:"failed" before the cleanup runs.

What Claude Code does NOT do before killing servers

  • Does not close stdin (which would trigger clean transport shutdown)
  • Does not send any MCP protocol shutdown/close message
  • Does not suppress the onclosetype:"failed" transition during its own shutdown

Expected behavior

Servers that are killed by Claude Code's own shutdown sequence should not be reported as "failed".

Suggested fixes

  1. Suppress onclose during shutdown: set a flag before sending SIGINT, and check it in the onclose handler to avoid setting type:"failed" when the close was initiated by Claude Code's own shutdown.
  1. Close stdin first: before sending SIGINT, close the write end of the server's stdin pipe. Most MCP servers (including FastMCP) treat stdin EOF as a clean shutdown trigger and will close the transport gracefully, so onclose would not fire unexpectedly.
  1. Reverse the shutdown order: close the MCP client (q.close()) first (which closes stdin), let the server shut down cleanly on transport EOF, then send SIGINT only if the process is still alive.
  1. Filter "failed" during shutdown: when rendering the session summary, exclude servers that were "failed" only because of shutdown-initiated transport closure.

Environment

  • Claude Code 2.1.70
  • Linux x86_64
  • MCP servers: Python FastMCP (mcp SDK), stdio transport
  • Two servers tested: karellen-rr-mcp, karellen-lsp-mcp

View original on GitHub ↗

This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗