[BUG] Claude Desktop leaks stdio MCP server processes when a renderer releases its port

Status Open
Maintainer reply None cached
Activity 3 comments · opened Aug 21, 2026

Summary

When the renderer that owns a stdio MCP server releases its port, Claude Desktop tears the server down and starts a replacement — but the replaced process keeps running with no client for as long as the app is open. Desktop logs the shutdown and appears to believe it succeeded.

The abandoned process never observes EOF on stdin, which is how a stdio MCP server is supposed to learn its client is gone. That points at an inheritable duplicate of the child's stdin write handle surviving somewhere in the Desktop process after Desktop closes its own copy.

Related but distinct: #84240 is the same class of bug in the VS Code extension (tab switching), and #1935 covers Claude Code not reaping servers on exit. This one is Claude Desktop, on renderer port release rather than on exit, and it happens repeatedly during a single session rather than once at teardown.

Environment

| | |
|---|---|
| Claude Desktop | 1.34493.1.0 (Claude_1.34493.1.0_x64__pzs8sxrjxfjjc) |
| OS | Windows 11 Pro 10.0.26200 |
| MCP server | Python 3.14, stdio transport |
| Log | %LOCALAPPDATA%\Claude\logs\mcp.log |

What the log shows

One app start, one server, one teardown, one replacement. Timestamps are the log's own (UTC), server pids added for clarity:

13:49:26  Initializing server...                                          -> pid A
13:49:27  Server started and connected successfully
13:49:27  initialize / notifications/initialized / tools|prompts|resources list   (all ok)
13:58:07  Client transport closed
13:58:07  Server transport closed (renderer released port); shutting down server
13:58:07  Shutting down server...
13:58:08  Server transport closed
13:58:23  Initializing server...                                          -> pid B
13:58:24  (a new renderer window finishes loading)

pid A was still running ten minutes later, with no client. Its process shape was that of a healthy server, not one that had unwound its transport — 25 threads / 250 handles against the live server's 24 / 240 — i.e. still blocked in its stdin read.

Frequency

Over six weeks of a single mcp.log:

  • Initializing server...366
  • Server transport closed (renderer released port)136
  • Server transport closed unexpectedly (server exited on its own) — 32

So roughly one orphan per renderer teardown, 136 times in six weeks, on one machine.

Impact

Each orphaned server held ~810 MB of private commit in our case (the server keeps an embedding model resident). Working set badly understates this — Windows trims it under pressure — so Task Manager makes it look far smaller than the actual commit charge. On a 16 GB machine a few orphans is real pressure, and nothing surfaces it, because Desktop's own log says the server was shut down.

Any stdio MCP server is affected. The size of each leak is whatever that server holds.

Reproduction

  1. Register any stdio MCP server in claude_desktop_config.json.
  2. Start Desktop, let the server complete its handshake, note its pid.
  3. Cause the owning renderer to release its port (reload / navigate the window).
  4. In mcp.log: renderer released port; shutting down server, then a fresh Initializing server....
  5. The pid from step 2 is still running — and still will be an hour later.

The server side handles EOF correctly

Worth ruling out, since it is the obvious first suspicion. A harness that completes a real MCP handshake against the same server and then closes only its write end of the child's stdin gets a clean exit: rc=0 in about 2 seconds.

The same server, given Desktop's actual teardown shape — stdin left open, only the stdout read end closed — was still alive after 25 seconds and indefinitely thereafter.

Expected

After Shutting down server..., the child exits: either because its stdin reaches EOF, or because the client kills it after a grace period.

Actual

The child never sees EOF and is never killed. It runs until the app exits.

Two possible fixes, either sufficient

  1. Close every duplicate of the child's stdin write handle at teardown. On Windows, spawning later children with handle inheritance enabled duplicates an earlier child's pipe handles into unrelated processes, which is the classic cause of exactly this symptom.
  2. Kill the child after a grace period if it has not exited once the transport is closed. The MCP TypeScript SDK's StdioClientTransport.close() already holds the process handle.

Workaround, for anyone hitting this before it is fixed

Have the server watch the other direction, since that half of the teardown is observable: a zero-byte WriteFile to its own stdout fails with ERROR_NO_DATA (232) once the client's read end is closed, and it writes nothing at all, so it can be issued between JSON-RPC messages without touching the protocol stream. Two consecutive dead probes, then exit 0. Measured: the probe returns success while a reader is attached and flips on the first poll after the reader closes, then stays there.

Two notes for anyone copying this:

  • os.write(1, b"") does not work — the CRT short-circuits a zero-length write and reports success forever. It has to go through WriteFile.
  • An idle timer is the wrong instrument. A session nobody has typed into for an hour is indistinguishable from an orphan from inside the server, and Desktop does not respawn a stdio server on demand, so a false positive costs a live session its entire tool surface.

One incidental note for anyone debugging this

Desktop appears to have moved its log directory. %APPDATA%\Claude\logs is the pre-move location and still holds a complete, convincing, stale history; the live one is %LOCALAPPDATA%\Claude\logs. We initially concluded the extra process "left no trace in Desktop's logs" — it was in the log we were not reading.

View original on GitHub ↗

3 Comments

MohannadReyadAlbujoq · 9 days ago

Correcting my own report, and adding a second, more common shape of the same bug that I found after filing.

Correction: Desktop closes neither pipe handle

My original write-up said the stdin write handle survives. It's worse than that, and the workaround I posted does not work against Desktop. Measured by shipping it and watching:

A server built with the zero-byte WriteFile probe exits in 10.0s in a controlled test where the parent closes the child's stdout read end. Against a real Desktop teardown, the same build does not exit — the discarded server was still alive 96 seconds later with a perfectly writable stdout.

So Server transport closed in mcp.log is a JS-layer event only. Both OS pipe handles stay open, which means an abandoned server has no pipe-level way to detect that it has been abandoned.

A second shape, and it happens at every boot

The original report covered the mid-session renderer released port teardown. There's a more common one. From a cold start, with server pids added:

14:55:25.578  Initializing server...                            -> server A
14:55:25.591  Server started and connected successfully
14:55:25.921  Shutting down server...
14:55:25.921  Server transport closed (intentional shutdown)      <- 330 ms after start
14:55:27.934  Initializing server...                            -> server B
14:55:27.944  Server started and connected successfully
14:55:27.948  Message from client: method="initialize" id=0       <- only B is spoken to

Desktop starts a server, connects it, discards it 330 ms later without ever sending initialize, then starts a second one and uses that. Server A survives with no client.

This happens on every Desktop boot, which explains a long-standing puzzle for anyone running a heavyweight MCP server: you always see exactly two server processes, one of them permanently idle. It isn't a duplicate spawn — it's a discarded first attempt that never dies.

I don't know what triggers the immediate replacement (config reload? profile/window resolution finishing?), but from the server's side the observable facts are: connected, never spoken to, discarded, still running.

Workaround that does work

Since neither pipe closes, the only reliable signal left is the absence of the handshake itself: exit if no JSON-RPC line has arrived within N seconds of start (I use 120).

The safety property is that this must latch — it asks whether a client ever spoke, not whether one has spoken recently, and the flag never decays. That distinction is what keeps it from being an idle timer: a session that has been quiet for an hour has long since latched and can never be reaped. Only a server that never completed a handshake at all can trigger it.

The margin is large: the spec makes initialize the client's first request, and Desktop sends it 4 ms after connecting, against a 120-second deadline.

Measured, spawning the server and then closing nothing and sending nothing — exactly Desktop's shape:

| build | result |
|---|---|
| zero-byte pipe probe only | still alive after 30s |
| plus handshake deadline | exits rc=0 |

I'm keeping the pipe probe as well, since it is the correct signal for any client that closes its pipes properly, and it is the only thing that could ever catch the mid-session case (a server that did handshake and is later abandoned). That case remains uncovered by anything I can detect from inside the server — worth noting in case it affects how you prioritise this.

Suggested fix, unchanged in substance

Closing the child's pipe handles at teardown fixes both shapes at once, and is what a stdio MCP server is entitled to expect. Failing that, killing the child after a grace period once the transport is closed would also do it — and would additionally cover the boot-discard case, where the server is dropped before it is ever used.

MohannadReyadAlbujoq · 9 days ago

Third and final correction from me, and this one narrows the report considerably. Please read it before acting on the earlier comments.

The second server process is usually legitimate, not an orphan

I said the pair of server processes users see is one live plus one discarded. That is wrong for most boots. There are two independent MCP subsystems in Desktop, and each spawns its own copy of every configured stdio server.

Only one of them writes to mcp.log. The other logs exclusively to main.log, which is why I spent three sessions concluding a process had "no trace in the logs":

15:35:41  MCP Server connection requested for: vscode-orchestrator
15:35:41  Launching MCP Server: vscode-orchestrator                 -> process 1
15:35:42  [LocalMcpServerManager] Connecting to vscode-orchestrator
15:35:42  [LocalMcpServerManager] Using MCP server command: ...     -> process 2
15:35:47  [LocalMcpServerManager] negotiated protocol version: 2025-11-25
15:35:47  [LocalMcpServerManager] Connected to vscode-orchestrator (62 tools)

mcp.log records only the first. The second completes a full handshake, negotiates a protocol version, and is announced with its tool count — a working connection, used by the local-sessions surface. Mid-session sightings are the same manager reconnecting after a [warn] ... disconnected.

So the memory cost of "two servers" is mostly expected behaviour, not waste. That is worth knowing before anyone treats this report as a leak of that magnitude.

I found this by measuring rather than reasoning: I shipped a watchdog that exits when no client ever handshakes, predicted it would reap the un-logged server, and it did not. The mechanism was right; my premise that nothing was talking to that process was wrong. It had received initialize like any other client.

What genuinely leaks, and it is narrower than I first reported

Servers whose teardown is logged in mcp.log and which nevertheless keep running:

  • Server transport closed (renderer released port); shutting down server — mid-session replacement.
  • Shutting down server... (intentional shutdown) 330 ms after start, with initialize never sent — the server is discarded before it is ever used, and a replacement is launched.

In both cases the process outlives the teardown, because both pipe handles stay open and stdin therefore never reaches EOF. That part of the original report stands and is what I would still ask you to look at.

Corrected workaround note

The zero-byte WriteFile probe from my first comment still does not fire against Desktop, for the reason in my second comment. The handshake-deadline signal does reap the genuinely-discarded servers — and, importantly, it correctly leaves the second subsystem's server alone, because that one does handshake. If you implement something similar, the latching property is what makes it safe: ask whether a client ever spoke, never whether one spoke recently.

Apologies for the churn

Three comments to get to the real shape of this. The underlying issue — a discarded stdio child that never exits because neither pipe is closed — is unchanged and reproducible. The scale claim was not.

Digitalbil · 1 day ago

@MohannadReyadAlbujoq
I have a fix for this not a workaround but an actual fix. Please see this branch i just published it. Please let me know if you run into any issues when using it but i believe you will not have any issues while putting it in place. Feel free to share this with anyone else that you run into who experiences it. I found it truly annoying which is why i wanted it resolved asap. Click for Branch with Fix