Skip to content

[BUG] stdio MCP server orphaned on /mcp reconnect after a .mcp.json edit - no kill, no stdin close, leaks until session exit #79740

Description

@toverux

Preflight Checklist

  • I have searched existing issues and this hasn't been reported yet
  • This is a single bug report (please file separate reports for different bugs)
  • I am using the latest version of Claude Code

What's Wrong?

/mcp → Reconnect handles the old stdio MCP server process in two very different ways depending on whether the server's .mcp.json entry changed since it was spawned:

  • Config unchanged (correct): Claude Code closes the old server's stdin (EOF), waits for the process to exit, then spawns the replacement. The old server shuts down cleanly.
  • Config edited (bug): Claude Code spawns the new server and abandons the old process tree entirely: the old server is not killed, its stdin is never closed (Claude Code keeps the pipe write end open), and no signal of any kind reaches it. A well-behaved server that exits on stdin EOF has nothing to react to and leaks for the rest of the session. The orphan only dies when the Claude Code process itself exits and the OS closes its pipe handles, finally delivering the EOF.

Editing .mcp.json and reconnecting is a supported flow (see #58122, which documents that reconnect picks up .mcp.json edits without a restart), so this is easy to hit during MCP server development: every config tweak + reconnect leaks one full server process tree.

For servers holding exclusive resources the orphan is not just a memory leak. Observed real-world case (a C# debugger MCP server launched via dotnet run): the abandoned instance kept holding an exclusive debugger socket and file locks on its own build output, so subsequent reconnects failed (MSB3027 file-lock build errors → JSON-RPC -32000 on connect) until the orphan was killed by hand.

Verified process evidence from one session (same repo, same config entry, one .mcp.json env edit between reconnect 1 and 2):

Event Old server New server
Reconnect, config unchanged got stdin EOF, exited; replacement spawned only after its exit spawned 12:39:32
Reconnect, config edited no EOF, no kill; still alive 180 s later (and until killed manually) spawned 12:40:55 while the old one still ran

What Should Happen?

A reconnect after a config edit should shut the old server down exactly like a config-unchanged reconnect does: close its stdin, give it a grace period to exit, then kill the process (tree) if it did not — before or while spawning the replacement.

Error Messages/Logs

(secondary symptom, when the orphan holds file locks on a dotnet-run-based server's build output;
the leak itself is silent)
error MSB3027: Could not copy "obj\Debug\net10.0\X.dll" to "bin\...\X.dll".
Exceeded retry count of 10. Failed. The file is locked by: "X (12345)"
MCP error -32000: Connection closed

Steps to Reproduce

Uses a minimal dependency-free stdio MCP server that logs its lifecycle to leaktest.log next to itself and exits on stdin EOF (the canonical stdio lifetime tie).

leaktest-server.mjs:

#!/usr/bin/env node
// Minimal stdio MCP server that logs its lifecycle to leaktest.log (same directory).
// It exits on stdin EOF, the canonical lifetime tie for a stdio MCP server.
import { appendFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createInterface } from "node:readline";

const logFile = join(dirname(fileURLToPath(import.meta.url)), "leaktest.log");
const log = (msg) =>
  appendFileSync(logFile, `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);

log(`started (marker=${process.env.LEAKTEST_MARKER ?? "unset"})`);

const respond = (id, result) =>
  process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);

createInterface({ input: process.stdin }).on("line", (line) => {
  let msg;
  try {
    msg = JSON.parse(line);
  } catch {
    return;
  }
  if (msg.method === "initialize") {
    respond(msg.id, {
      protocolVersion: msg.params.protocolVersion,
      capabilities: { tools: {} },
      serverInfo: { name: "leaktest", version: "1.0.0" },
    });
  } else if (msg.method === "tools/list") {
    respond(msg.id, { tools: [] });
  } else if (msg.id !== undefined) {
    respond(msg.id, {});
  }
});

process.stdin.on("end", () => {
  log("stdin EOF received");
  process.exit(0);
});

process.on("exit", (code) => log(`exiting code=${code}`));

.mcp.json in the same directory:

{
  "mcpServers": {
    "leaktest": {
      "command": "node",
      "args": ["leaktest-server.mjs"],
      "env": { "LEAKTEST_MARKER": "a" }
    }
  }
}
  1. Start claude in that directory and approve the leaktest server. leaktest.log shows pid=A started.
  2. Control run: /mcpleaktest → Reconnect without touching the config. The log shows pid=A stdin EOF received + exiting code=0, then pid=B started. Correct.
  3. Edit .mcp.json: change LEAKTEST_MARKER from "a" to "b" (any edit to the entry reproduces).
  4. /mcpleaktest → Reconnect. The log shows pid=C started (marker=b) — and nothing more for pid B, ever: no EOF line, no exit line. Task Manager shows pid B still running.
  5. Exit Claude Code entirely: only now does the log show pid=B stdin EOF received (the OS closed the abandoned pipe handles).

Claude Model

Irrelevant

Is this a regression?

I don't know

Claude Code Version

2.1.216 (Claude Code)

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

Warp

Additional Information

Related issues (searched, none covers this):

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