The night before this, the same job took my whole Hetzner box down. Silent — no alerts, no SSH, no ping — until a hardware reboot from Hetzner's panel brought it back the next morning. How an uncapped machine dies quietly from swap thrashing is a story of its own. This one is about the small, app-level cause I found when I dug in.
I'd set pks brain extract running across ~6,700 sessions. It builds an AI summary per session by spawning one Claude worker at a time. While it ran, I watched the machine — and memory just kept climbing.
It climbed. 30 GB in one process family, 600+ processes, and it came back every time we cleaned up. The easy explanation was "the extract leaks." It was wrong.
The learning
The extract was well-behaved — the Claude workers sat flat at ~9 GB. What grew was something else entirely: every spawned worker loaded the project's .mcp.json on startup and launched an Aspire MCP server it never needed. That MCP server fired a nuget search that hangs in our devcontainer and turns into a zombie. One leak per worker. Thousands of workers = thousands of hung processes.
The point: an extract worker reads JSON and writes markdown. It needs zero tools. But it inherited my entire MCP stack, because that was the default — and the default is tuned for my interactive dev session, not for a batch worker that runs a thousand times.
The fix was one line on how we spawn Claude:
claude --print --strict-mcp-config # ignore .mcp.json entirely — load zero MCP servers
With --strict-mcp-config the worker ignores .mcp.json completely. My interactive Aspire MCP stayed in the config for my own work — only the batch workers skip it. The proof landed immediately:
✓ leak-free · 15.8GB used · claude 32/9.6GB · aspire 0 · aspire-managed 0
32 workers running at a flat ~9.6 GB, and zero Aspire processes. The rest of the run — 41 minutes — held flat.
What I took away
Every MCP server costs memory and startup per Claude. On one interactive session you never feel it. But the moment you spawn Claude programmatically — in a loop, in a batch, one worker per item — that cost multiplies by your worker count, and an MCP server that's just slightly wrong in that particular environment becomes a leak that scales with your load.
So: when you spawn a Claude for one narrow job, give it only what the job actually needs. For a worker that just reads and writes files, the right number of MCP servers is zero. Spawn narrow.
(And then there's the other layer: even a well-behaved app shouldn't be able to take down a whole machine. That's the host's job to prevent — with per-container memory limits. That's the host-level story from that night.)
Show the full debug trail
What finding the root cause looked like
The first suspicion was always wrong: that it was the extract itself. Separating "the worker" from "what the worker launches" was the whole exercise.
First: what's actually eating memory? Not Claude.
Found it — and it's not the extract. The extract is well-behaved (claude ~8 GB flat, pks parent 0.16 GB). The memory is dominated by something else:
aspire-managed— 454 processes, ~30 GB RSS ← this is the real consumer and likely what's growing. 454 processes under one name is abnormal — smells like orphaned/leaked processes.
(verbatim from the session)
Then: killing them made it worse. The harness restarts the MCP server every time it dies:
The Claude Code harness auto-restarts the MCP server every time I kill it, and each restart fires a fresh hung
nuget search. So killing is whack-a-mole — it actually makes it worse (kill → harness restarts → new hung search).
Then: the actual mechanism. This is where it clicked:
pks brain extractruns aclaude --printworker per session, and every worker loads.mcp.jsonon startup → launches theaspireMCP server (aspire agent mcp) → which fires a hungnuget search. So the leak scales with extract workers — thousands of extracts = thousands of orphaned nuget procs.
The first fix was a stopgap — deleting aspire from the shared .mcp.json. It worked, but it hit every session, including my interactive dev session, which wants the Aspire MCP. The right fix is per-spawn:
The proper fix is confirmed feasible — add
--strict-mcp-configto the extract's claude spawn so workers ignore.mcp.jsonentirely (no MCP servers at all, which is exactly right — extract workers only read JSON and write markdown, they need zero tools).
The line lives in external/pks-cli/.../ClaudeRunner.cs. Afterwards we set a monitor to scream ⚠ FIX FAILED the instant any Aspire process appeared during the run — and it stayed quiet for the remaining 41 minutes (one false alarm: my own VS Code terminal legitimately loading the Aspire MCP). That's the difference between believing a fix holds and proving it does.
