Repository navigation
mcp: global stdio servers spawn once per loaded directory and exhaust memory #51003
Description
Summary
OpenCode 2 starts a separate copy of every global stdio MCP server for each loaded directory. A client that has many directories open, such as OpenChamber with one directory per chat, multiplies those processes until the host runs out of memory and API health checks time out.
Environment
- opencode version: 2.0.15
- OS: Linux 6.8.0-139-generic Theme/catppuccin mocha theme #139-Ubuntu SMP PREEMPT_DYNAMIC x86_64
- Terminal: xterm-256color
- Shell: /bin/bash
- Install/channel: latest,
~/.opencode/bin/opencode - Active plugins:
@knikolov/opencode-plugin-simple-memory@2.0.0, local pluginopenchamber-agent-tool - Client: OpenChamber 2.0.0, which starts
opencode serveand passes?directory=for each chat folder
Reproduction
- Configure several local stdio MCP servers in the global
~/.config/opencode/opencode.jsonc(mcpservers of typelocal). - Start
opencode serve(OpenChamber does this as a managed child). - Open or poll many directories. Each chat in this setup is its own directory under the client data folder. A directory-scoped read such as
GET /api/session?directory=<path>&limit=1creates a location and connects that location's MCP servers. - Watch the serve process tree and
GET /api/debug/location.
Expected Behavior
Global stdio MCP servers should be one process per server, shared across locations, or started lazily and released when a location is idle. Idle locations should not keep a full MCP fleet resident, and a client with many directories should not be able to exhaust host memory just by listing them.
Actual Behavior
Each loaded directory gets its own fleet. On a 15 GB host, 29 loaded directories produced about 175 MCP child processes (freshdesk, imap, spotify, blender, and their helpers) under the managed opencode serve pid. RSS for that process was about 900 MB and the host was at 12 GB used, with periodic OpenChamber health checks failing as class=timeout.
location services evicted does run, and that directory's MCP processes are replaced rather than left orphaned: a directory evicted at 00:27:46 UTC had new MCP pids starting at 00:29:08 UTC, when logs showed mcp connected again for the same directory. The problem is the steady state. Directories are re-touched after eviction, many stay loaded together, and each one holds a full copy of the global servers. Only one session was active (GET /api/session/active) while dozens of locations were still loaded.
This made the OpenChamber iOS client, which reaches this server over Private Relay, hang even though the relay token itself was still accepted.
Additional Context
- Frequency: consistent after the v2 cutover. The managed server had been up about 90 minutes.
- Related: serve: per-request instances are never disposed - MCP child processes accumulate until memory exhaustion #47727 (v1 per-request instances never disposed when a client polls
?directory=). This is the v2 form: locations are created per directory and eviction exists, but each live location still owns a full stdio MCP fleet. - Related: server: no SIGTERM handling, MCP stdio (docker) children orphaned on every stop/restart #50780 (MCP children not closed on SIGTERM, and idle eviction not releasing docker MCP). Here eviction does log and the old pids for that directory do disappear, then a new fleet is started as soon as the directory is touched again.
- Workaround tried: none that keeps the MCP servers available. Restarting the serve process clears the children, but they return as directories are loaded again.
Activity
Nasnl commentedon Sep 25, 2026
Additional Windows evidence for the per-location fleet: respawns are driven by a
repeatable eviction cycle, and a malformed phantom location gets a full fleet on
every cycle.
Environment: Windows, OpenCode Desktop/CLI 2.0.16 (service
opencode-cli.exe serve --service), 5 configured local stdio MCP servers, plugins enabled.Evidence from
~/.local/share/opencode/log/opencode.log(grep
location services evicted/mcp connected).1. Per-directory eviction runs on a ~61-minute cadence (UTC, same directory):
The user-home directory shows the same +61m07s / +61m15s pattern. Nothing
external precedes any eviction in the log — it reads like an internal TTL sweep.
2. Each eviction rebuilds that directory's full MCP fleet within ~16s:
So the fleet is fully replaced per directory every cycle — matching the
"groups of five" console flashes reported upthread on PR #45259 (each burst
reconnects exactly 5 local MCPs through the cmd.exe/npx chain).
3. Phantom location
directory="\\"(bare backslash) gets the same treatment:workspaceID=undefined; 14 boots inthe log; participates in every eviction/reboot cycle like a normal location
watcher subscribe path="C:\" type=entries→location services booted directory="\\"So per-sweep process count = directories actually in use + this phantom. The
watcher correctly targets
C:\while the location key comes out as\, whichlooks like a root-path normalization issue.
Worktree.DirectoryUnavailableErrorfailures run through the same bootstrap but timing is inconsistent, so I can't
establish causation from logs alone.
Ask: any way to stop the eviction-cycle respawn for idle locations, and any way
to suppress/deregister the
"\\"location. I can provide redacted log excerptsor run a verbose-trace startup to capture who first requests it.
Kirytho commentedon Sep 27, 2026
Data point from 2.0.18 (released), which reframes this as a broken invariant rather than only a scaling concern.
Environment: 2.0.18, Linux,
opencode serve --service, 6 local stdio MCP servers + 1 remote, one TUI, one directory.After a single client disconnect/reconnect cycle I ended up with two complete sets of the 6 local servers — 12 processes, ~789 MB RSS — where the service reports only 7 live connections (
opencode mcp list). Both sets are annotateddirectory=/home/ferandworkspaceID=undefined, i.e. the same canonical Location key percanonical()inlocation-service-map.ts:39-44. Twomcp connectedbursts, 2 minutes apart, the second immediately after the client reconnected. Zerosession expired/ reconnecting events, and only 2 teardown events in the entire log, so the first set was never disposed.The part I think matters most:
packages/core/src/mcp/stdio.ts:39-40documents an explicit guarantee —If a second set exists for a Location whose scope was already released, that guarantee is not holding. That would make this a correctness bug in the scope/finalizer path, not only the per-Location cost model that this issue and #37844 describe.
I checked the paths that could plausibly own this and they all look correct:
replaceServer(mcp/index.ts:493) disposes the previous entry,connect(:633),reconnect(:578) andrecover(:333) each callstopServerbeforestartServer, andstartServercloses its forked scope on the failure branch (:458). So I could not find the culprit by reading, only the symptom.What I could not determine: I was unable to authenticate to
GET /api/debug/location, so I cannot confirm from the server's own view whether this is one Location built twice or two Locations. If someone can run that endpoint after a reconnect cycle,connectionIDin the log lines distinguishes the builds.Not filing a separate issue — this looks like the same bug.
Kirytho commentedon Sep 27, 2026
Correction to my comment above — I found the trigger, and it is not what I described.
I claimed the duplicate set came from a client disconnect/reconnect. That was wrong. The trigger is
opencode reload: every reload re-boots the Location layer graph, and the previous boot's MCP subprocesses are not reaped.Evidence from a cold
opencode service restartwith no client churn at all:A single reload took the process tree from 6 local MCP servers to 12 (~890 MB). Across the whole log: 25 reloads, 51
location services booted, and only 2event stream disconnected. The boots track reloads, not reconnects.Two consequences:
This is already reported, and more precisely, in server: stdio MCP child processes not terminated on service restart / config reload #50758 ("stdio MCP child processes not terminated on service restart / config reload"). That title names the trigger outright. I had it in my search results while investigating and did not connect it. Pointing there rather than here.
It contradicts the framing in serve (1.18.25): MCP child processes accumulate on web-client reconnects until the server OOMs #46035 ("accumulate on web-client reconnects") — at least for the
servepath. Reconnects were only 2 in my log; reloads were 25. If reconnects are the trigger there, it is a different code path from this one, and worth separating.Also worth noting for whoever picks this up: the reload rebuilds plugins as well as MCP —
plugin reconciliation startedappears 4 times for 2 boots — so this is the Location layer graph being rebuilt, not an MCP-specific leak. Thestdio.ts:39-40guarantee ("closing the scope kills it... regardless of whether the transport was closed") is still the right thing to check, but the scope being closed is the reload path, not a client disconnect.Retracting the reconnect hypothesis from my previous comment.
kernel-oops commentedon Sep 27, 2026
Another data point, plus a workaround that has held up for us (v2.0.16 fork, single long-lived
serveon Linux, busy multi-agent use with many git worktrees).Scale. After about 45 hours the server had 50 loaded locations (one per worktree or project directory any session had touched) and therefore ~150 stdio MCP helper processes for three global local servers (50 + 50 + 49). Server RSS was ~10 GB, CPU sat at 100–120%, and the API periodically stopped responding altogether for up to ~2 minutes (one captured freeze: no request served for 2m20s), while agents carried on afterwards.
Workaround. Run each stdio helper once, behind a small loopback bridge that speaks streamable HTTP, and point the global config at it as a remote server:
The bridge initialises the helper once, answers each client's
initializefrom that result (echoing the client's protocol version), and remaps JSON-RPC ids so concurrent locations never collide. For plain tool servers (no server-initiated notifications or sampling) that is all that's needed. After switching, every location connects to the same three processes instead of spawning its own; RSS after restart was ~0.5 GB.Caveat, consistent with the reload finding above: make the config change while the server is stopped. Changing MCP config on a live server reloads the locations and the previous helper processes are not reaped.
A proper fix would still be preferable: sharing one instance of each global stdio server across locations (reference-counted, torn down on config reload), or spawning lazily and releasing with the location's idle expiry.
titoune33 commentedon Oct 14, 2026
Data point that narrows the leak (macOS, current
dev).@Kirytho's "every reload re-boots the Location layer graph and the previous boot's MCP subprocesses are not reaped" is the right trigger. But the reaping contract at the
RcMap/LayerMaplayer is actually sound — it does not orphan the entry scope on reload. I added a runnable probe (PR #55548) and it passes both paths ondev:reload()→invalidatecloses the old entry scope eagerly.invalidatedetaches it, and the entry scope closes when the last borrower releases (RcMap.releasesees the detached entry and callscloseEntry).Relevant ownership:
Mcp.nodeis a location node (packages/core/src/mcp/index.ts→makeLocationNode), included inInstance.layer, and each stdio server forks its scope from the location entry'sroot(Scope.fork(root), killed on scope close). So the fleet should die with the entry scope — which, per the probe, does close on reload.So the leak is not "reload leaves the entry scope alive." It's one of:
locations.get(ref)/contextEffect(ref)in a scope that outlives the reload, so the old entry's refCount never returns to 0 andcloseEntrynever runs. Each reload then strands one fleet behind that permanent borrow.config.updated→mcp.reload()re-forking fromrootwithout closing the prior per-server scope.Suggest the next repro step is to instrument the refCount of the detached entry across a reload (log before/after
invalidate), or toGET /api/debug/locationand check whether the old location is still listed as loaded after reload. If it is, it's (1) and the fix is the borrow discipline, not the layer.mlimarenko commentedon Oct 15, 2026
@titoune33 The probe result you report helps separate the reload investigation from #53046. That PR defers MCP startup and releases discovery-only connections after 60 seconds; a connection used for a tool, prompt or resource read, or explicitly connected, stays retained until disposal. It does not change Location borrow ownership, so it should not be treated as a fix for a stale borrow across reload.
In the rebased code, MCP replacement still follows
disposeServer→stopServer→Scope.closebefore starting the replacement. I have not reproduced either proposed reload leak path, and #53046 does not claim to fix #50758. Recording the old entry's borrow count alongside per-server scope closure during a reload would distinguish those paths without assuming that invalidation itself leaves the entry scope open.CerisonAutomation commentedon Oct 22, 2026
Confirmed on opencode 2.1.2 (Homebrew), macOS 27.0.1.
With exactly two MCP servers configured (
caveman,supabase), the server spawns a separate set of child processes per loaded directory.mcp connectedevents, grouped by server and directory, from a single server log:45 connect events for 2 configured servers across 3 directories. In
psthis showed up as 9 concurrent children under a singleopencode serve --servicePID — 3 x caveman + 3 xnpm exec mcp-remote+ 3 xnode mcp-remote.Two amplifiers I found while measuring this:
The
npm execwrapper. Withcommand: ["npm","exec","mcp-remote","--","<url>"]each spawn costs an extra npm wrapper process (137-187 MB RSS per location in my measurement) on top of thenodechild. Pointingcommandat the resolved binary instead —node <path>/node_modules/mcp-remote/dist/proxy.js <url>— removes the wrapper entirely: 9 processes / ~800 MB -> 9 processes / ~447 MB, with identical behaviour (verified viamcp connectedplus a successful tool round-trip).One of the per-location directories is RAM-backed. It is the agent scratch dir under
/private/var/folders/.../T/opencode, so that instance's working set competes with the compressor for the same physical pages. That is why this presents as system-wide memory pressure rather than as one hot process. See [FEATURE]: Don't default agent scratch dir to a RAM-backed /tmp #55006.Practical note for anyone triaging this: killing duplicates blindly breaks the live session. The reliable attribution method is to snapshot per-PID CPU time, issue one tool call that does real work through the server, snapshot again, and diff — the instance whose CPU time moved is the one serving the session; the others are idle leftovers.
A fix that reuses one stdio server process per (server config, workspace) pair instead of per location would remove the fan-out entirely.
btriapitsyn commentedon Oct 22, 2026
I maintain OpenChamber, the client in this report. I dug into a similar report on our side, openchamber/openchamber#5412, and here's what we found and changed.
@ianrelecker the OpenChamber half of your setup is fixed. Since 2.5.0 we release a chat's location 30 s after you leave it and a worktree's after 5 min, through
DELETE /api/debug/location. We also stopped reading a left directory's MCP status after its shutdown, which used to start it again within a second. The directories you saw re-touched after eviction were most likely us.What remained was the directory on screen. On
location.shutdownOpenChamber bootstrapped it again about 30 ms later, so an app left open overnight brought it back with every MCP server once an hour, and each remote server went through OAuth with an expired token. In our next release it waits until the window is focused again. Projects get released too now, 15 min after the user leaves them. Our diagnostic export, Cmd/Ctrl+Shift+L, now shows how many locations OpenCode has loaded and how many processes it started, so the next report like this comes with those two numbers.I also tried to catch eviction leaking processes and couldn't. I put a fake stdio MCP behind a bash wrapper, the way uvx starts python, and evicted its location mid-handshake at delays from 0 to 2 s, after connect, and with an
/api/shellcommand running: zero survivors on 2.0.32 and on 2.1.2. 60 evict/boot cycles with remote servers keptopencode servebetween 275 and 330 MB.So the client can release locations sooner, and that's about all it can do. The memory goes into one copy of every global stdio server per location, and sharing identical ones across locations, as #37844 lays out, is the part only OpenCode can fix. The user in our report had 11 project and worktree directories open, which meant 11 copies each of
codegraphanduvx mcp-proxy-for-aws.mlimarenko commentedon Oct 23, 2026
@btriapitsyn Your client-side release changes and #53046 address different parts of the fan-out. That PR defers configured MCP startup until MCP use and closes discovery-only connections after 60 seconds, keeping their catalogs cached. Tool, prompt or resource use, an elicitation, or an explicit connect retains the connection until disconnect or disposal. It does not share a process across locations, so directories that genuinely use the same server still get separate instances.
@CerisonAutomation That distinction also applies to the per-directory copies you measured: #53046 can reduce discovery-only residency, but it is not global stdio pooling or a fix for every reload/process-cleanup report. I have not reproduced your macOS setup or the memory figures in these comments.