[BUG] stdio MCP server orphaned on /mcp reconnect after a .mcp.json edit - no kill, no stdin close, leaks until session exit
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] 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" }
}
}
}
- Start
claudein that directory and approve theleaktestserver.leaktest.logshowspid=A started. - Control run:
/mcp→leaktest→ Reconnect without touching the config. The log showspid=A stdin EOF received+exiting code=0, thenpid=B started. Correct. - Edit
.mcp.json: changeLEAKTEST_MARKERfrom"a"to"b"(any edit to the entry reproduces). /mcp→leaktest→ Reconnect. The log showspid=C started (marker=b)— and nothing more for pid B, ever: no EOF line, no exit line. Task Manager shows pid B still running.- 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):
- #78551 — orphaned child processes (browsers) of an MCP server after reconnect; here it is the server process itself, and only on the config-edited path.
- #1935 / #76306 — orphans on Claude Code exit; here the leak happens mid-session, and session exit is actually what eventually cleans it up.
- #58122 — documents the
.mcp.jsonedit + reconnect flow this bug sits in.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Reproduced on the current release (2.1.233, Linux) using your leaktest server verbatim. Confirmed both halves of your report:
.mcp.jsonentry: the new server (new marker value) starts, and the old server process is abandoned — no stdin EOF, no signal. It was still running minutes later and only exited when the Claude Code process itself exited, exactly matching your lifecycle log table.So the old-server shutdown step is being skipped specifically on the changed-config reconnect path. Thanks for the excellent minimal repro and process evidence — flagging this as a confirmed bug for the team.
🤖 Generated with Claude Code
Corroborating real-world evidence from a different scenario: iterative local development of a stdio MCP server (editing the server's own Python source, then reconnecting via
/mcpto pick up the change — the exact "config/source changed between reconnects" path this issue describes).Over roughly 4 hours of a single working session on Windows, doing this repeatedly, we ended up with 5 separate full process-tree generations of the same server simultaneously alive (each a complete
uvshim →uv.exe→ server executable →python.exe→ interpreter chain), confirmed directly viaGet-CimInstance Win32_Process:None of the first 4 had exited on their own by the time we checked — matches this issue's description exactly (no stdin EOF delivered, no kill, leaked until manually terminated). We ended up manually killing the 4 stale trees by PID once we noticed, since nothing else was going to reap them.
This shows the leak compounds meaningfully in a completely ordinary "edit source, reconnect, test" development loop, not just the 2-reconnect minimal repro above — worth weighting the severity/priority accordingly, since local MCP server development is exactly the workflow this would recur in most often.
Platform: Windows, Claude Code CLI (local session, not Desktop/Cowork).
Corroborating from iterative local stdio MCP work: after a
.mcp.jsonedit +/mcpreconnect, the old child can stay attached to the session with open pipes while a new process is spawned for the same server name.Practical ops notes that have helped while waiting on a proper kill-on-reconnect:
npx -y <pkg>/ short-lived wrappers only if you accept cold start; long-lived local servers need a deliberate shutdown path when config changes.psfor multiple children of the same MCP command — that matches the orphaned-stdio pattern described here.Happy to share a minimal repro harness if useful (stdio echo server + reconnect loop).