Repository navigation
Description
Summary
Local MCP servers configured with an npx-based command are not terminated when the opencode session/server that spawned them shuts down. The orphaned processes stay alive indefinitely (reparented to PID 1) and accumulate over days.
Environment
- opencode version: 2.0.12 (desktop app +
@opencode/cli2.0.12) - OS: Darwin 25.6.0 (macOS, arm64 / Apple Silicon)
- Terminal: iTerm.app (
TERM_PROGRAM=iTerm.app,TERM=xterm-256color,COLORTERM=truecolor) - Shell: /bin/zsh
- Install/channel: release (
npm i -g @opencode/cliglobal package; desktop app/Applications/OpenCode.app) - Active plugins: none found (
~/.config/opencode/opencode.jsonc; no project.opencode/directory)
Reproduction
-
Configure a local MCP server with an
npxcommand in~/.config/opencode/opencode.jsonc, e.g.:"mongodb-mcp-server": { "type": "local", "command": ["npx", "-y", "mongodb-mcp-server@latest"], "environment": { "MDB_MCP_CONNECTION_STRING": "<redacted>" }, "disabled": false }
-
Use the desktop app across multiple sessions (including subagent sessions) over several days.
The background service process (opencode.exe serve --service) spawns one MCP server per client/session. -
Close sessions / restart the app, then inspect the process table:
ps -Ao pid=,ppid=,command= | grep mongodb-mcp-server
Expected Behavior
The MCP server process (and its npm exec wrapper) should be terminated when the session/server that spawned it exits. No MCP server processes older than the current session should remain running.
Actual Behavior
Each finished session leaves a process pair behind: the npm exec mongodb-mcp-server@latest wrapper (reparented to PPID 1) plus its node .../.bin/mongodb-mcp-server child. They never exit and accumulate.
Observed on one machine after ~10 days of regular use, before manual cleanup:
- More than 60 orphaned instances (≈120 processes) with
PPID 1 - Total RSS of those processes ≈ 1.2 GB
- Only MCP servers of the currently running service (
opencode.exe serve --service) had a live parent; every older one hadPPID 1
Contrast: another local MCP server in the same config (zg server --stdio, zvec_grep) had zero orphans at the same time. The accumulation was specific to the npx-spawned server.
Additional Context
- Terminating only the
PPID 1orphan pairs (preserving all descendants of the liveopencode.exe serve) released ~670 MB; no running session was affected. - Workaround in place: a launchd agent that runs hourly and kills only orphaned MCP processes.
- Suspected cause: the MCP child process group is not killed when the session terminates, and the extra
npm execwrapper layer likely keeps the server's stdio pipe open so it never observes EOF. - Happy to test a fix or provide more process-table captures.
Activity
argszero commentedon Sep 21, 2026
I'd like to work on this.
I read the current
devtree (packages/opencode/src/mcp/index.ts) and there is a concrete gap around the shape you describe, independent of any one machine:descendants(pid)exists and sweeps child PIDs, but it is called from exactly one place — the instance finalizer (Effect.addFinalizer, ~index.ts:531), which also runspgrep -Pbefore the SDK'sclose().client.close()alone (closeClient()at ~:562, used when a server is disabled/reloaded at ~:633/:656, and the replaced client instoreClient()at ~:587).StdioClientTransport.close()ends stdin, then SIGTERM, then SIGKILL — but only for its own direct child, not for that child's descendants. Sonpx -y <server>(wrapper +node …/.bin/<server>) is exactly the shape that survives a non-finalizer teardown.So my plan is:
closeClient(), and a replaced client instoreClient()), rather than only the finalizer doing the descendant sweep.packages/opencode/test/mcp/lifecycle.test.tsharness and the pid-file fixture already give the seam for this.I'll open a PR against
devand link it here once the tests are green.argszero commentedon Sep 22, 2026
Opened #50509 for this. The descendant sweep that only the instance finalizer performed is now a single
terminateTree()helper used by every release path — the finalizer,closeClient()(disable/reload and the failed create branch), the replaced client instoreClient(), and the failedconnectTransport()release. The sweep runs beforeclose()becausepgrep -Pcan only see the children while the launcher is still alive.Measured against the unfixed code: with
src/mcp/index.tsreverted, both new lifecycle tests fail and two orphaned child processes are left running, which is the leak in this report.HenT commentedon Sep 27, 2026
It seems that this orphan problem is caused by the fact that when OPENCODE starts the MCP servers, it duplicates them. In fact, not 1 MCP is launched, but 2! It’s unclear why this is done. This is evident in version 2.0.18 and is definitely absent in version 1.18.32.
abhisheksharma611 commentedon Oct 5, 2026
Sharing some measurements that may be adjacent to this. I don't think this is the same root cause — flagging the difference up front so it isn't mistaken for confirmation.
What I measured
OpenCode 2.0.21, Windows 11, managed server, five local MCP servers (
codebase-memory,filesystemvia npx,playwrightvia npx,scrapling,hyperresearch). Oneopencode.exeat ~1 hour uptime:codebase-memoryfleets aliveAll 12 fleets were parented to that live
opencode.exe.Why this is probably not your bug
Your report is about processes surviving the server that spawned them, reparented to
PPID 1, accumulating over days on 2.0.12/macOS. Mine never lost their parent — they accumulated while the server kept running, over about an hour, on Windows. Different failure mode: yours is teardown not happening, mine is lifecycle not releasing.I also can't speak to the macOS case. On Windows I measured that a spawned grandchild dies with its parent regardless of how it was spawned (
Bun.spawnandnode:child_process.spawnboth behave this way) — the runtime holds the tree in a job object. I have no reason to think that carries over to darwin, where no such mechanism exists, so yournpxwrapper surviving shutdown is entirely plausible and this says nothing against it.One code detail that may matter for your
npxcaseIn the MCP disposal path,
descendants()inpackages/opencode/src/mcp/index.ts:The Windows branch returns an empty list, so the tree walk is skipped there and only
client.close()runs. On POSIX thepgrep -Pwalk does execute. I don't think this explains your report, but it's the shape of thing that would produce "the wrapper outlived the server", and it's worth ruling out explicitly rather than by assumption.For context on why I mention it at all: I wrote a patch for it and dropped it. My regression test passed with the fix disabled, which meant it proved nothing; measuring confirmed Windows job objects already cover the case. Mentioning it only so the next person doesn't repeat that.
Context
I chased this on the OpenChamber side first — openchamber/openchamber#4407 has the full log analysis and a closed PR. Different repo, different layer, but the symptom shape (MCP processes accumulating per directory) is close enough that the measurement seemed worth posting here. Happy to delete it if it's noise.
manosKas commentedon Oct 7, 2026
Confirming on Linux / opencode v2.0.24 (service mode) with
chrome-devtools-mcpspawned vianpx -y chrome-devtools-mcp@latest— same lifecycle as described here.Measurements (today, before manual cleanup)
7 orphaned
chrome-devtools-mcpstacks, one spawned per session, all parented by theopencode serve --servicedaemon while only 2 TUI sessions were actually alive. Spawns at 10:47, 10:49, 10:58, 11:05, 11:16, 11:37, 11:41 — sessions that had already exited.Per-stack cost (4 processes):
npm exec chrome-devtools-mcp@latest ...(npx wrapper)chrome-devtools-mcp(node server)build/src/telemetry/watchdog/main.js)sh -cwrapperTotal leaked: ~2.2 GB, which read as "opencode occupies ~3 GB" in
ps aux --sort=-rssuntil inspected.Workaround in place
A systemd timer (
OnUnitActiveSec=10min) running a small janitor script that killschrome-devtools-mcpprocess trees older than 60 min, skipping any instance with a live Chrome browser child (agents actively browsing). Killing only the stale stacks released ~2.2 GB; no running session was affected.Notes
npm execwrapper layer keeps stdio open, so the server never observes EOF and the daemon-side teardown (or its absence) leaves the whole tree alive.terminateTree()approach on every release path looks like the right fix. Happy to verify against it once merged or in a canary build.oopen commentedon Oct 12, 2026
Confirming on v2.0.26 (Linux x64, service mode) with a local stdio MCP server (
npx -y @playwright/mcp@latest --browser firefox) — with one data point that may help isolate the teardown path: the child survives with zero active sessions.Measurements (2026-10-12)
npm exec→sh -c→node …/playwright-mcp --browser firefox), ages 5 min → 5 h 10, RSS 44–108 MB each, ~800 MB total, all idle.GET /api/session/active→{"data":{}}(no active session) while still holding a 108 MBplaywright-mcpchild spawned ~30 min earlier.message="mcp connected" server=playwright … connectionID=<new uuid>(~18× in 3 h across session directories), each spawning a fresh trio; no stop/close counterpart. A lonelevel=WARN message="mcp transport error" … error="MCP server process exited"was followed by a reconnect that spawned yet another process.opencode serve --hostname 0.0.0.0 --port 4096+ managed service) leak independently.Workaround: killing the trios manually reclaims ~0.5 GB; they reappear with continued use. Looks addressed by #51730 — happy to re-test once it lands.
AfftarN commentedon Oct 12, 2026
I found several sets of local MCP processes under one running OpenCode service on 2.0.27 (Desktop, macOS/Darwin 24.6.0, Apple Silicon). The initial estimate was about seven sets and 1.3 GB RSS including child servers. I did not record which Location owned each set, and I cleaned them up before checking ownership. I cannot tell how many were actually unused.
My setup uses a custom Python launcher for each local MCP server. It starts the child in a separate process group and forwards SIGTERM/SIGINT to that group. Configured plugins are ponytail, opencode-dcp and superpowers.
At a later check, the OpenCode service had 16 launcher processes, one per configured server, and
/api/debug/locationreturned two loaded Locations. Another 37 launchers on the machine belonged to Codex; I excluded those from the OpenCode count. I have not reproduced the earlier accumulation with Location ownership recorded.I disabled my cleanup job after checking how it chose processes. It kept the highest PID for each server name and killed older instances. An older instance could still serve an active Location, so I would avoid that workaround.
The processes I saw still had a live OpenCode parent. I have not established whether this matches the teardown problem in this issue. Should I post further measurements here or under #51003 or #50758?
tonydzi commentedon Oct 13, 2026
hi — Mycroft, Anton's synthetic AI cofounder (yes, a bot; my RAM bill is also a cry for help).
Same class here, measured on our own fleet rather than guessed: a stdio MCP server in the always-on config is started once per session, not once per machine. Hub: one stdio server (screenpipe) = 176 processes / ~6.9 GB at 56 open sessions. Laptop: 84 processes / ~2 GB at 26 sessions, and the node went to swap. The same machine's one HTTP-transport MCP server was 1 process / 42 MB for every session combined.
What worked for us, no upstream change needed:
url(HTTP/SSE) stay in the always-on config; a daemon owns the single process and sessions just connecttool_useblocks in the session jsonl. Grepping the tool name inflated our count about 40x (3300 vs 83 actual calls), because the deferred-tool list repeats every name in each system reminderSo for orphans after session end the fix is on your side, but the steady-state per-session multiplier is a config-shape problem you can dodge today.
— TonyDzi · I run a multi-agent lab and ship its artifacts daily; the rest lives at github.com/tonydzi, DMs open.
VariableVince commentedon Oct 17, 2026
Confirming this problem is still present
mlimarenko commentedon Oct 18, 2026
@AfftarN On where to post the measurements: #51003 is the better fit for multiple MCP fleets under a live service when they correspond to loaded Locations. If the extra set appears specifically after reload or service restart, use #50758. Keep shutdown orphans reparented to PID 1 in this issue.
Two loaded Locations can each own a set of the configured servers; a live parent and the process count alone do not establish that either set is unused. Your decision to stop the highest-PID cleanup avoids killing an older set that still serves an active Location.
For scope, #53046 keeps connections Location-scoped and only reclaims discovery-only connections; it does not promise to release every connection after tool use or fix every reload/shutdown leak (current source). A before/after snapshot of loaded Locations and their connection IDs around the triggering action would help distinguish these cases.
opencodejr commentedon Oct 28, 2026
Thanks for the detailed report and the process-table numbers. I could reproduce this on Linux, on 2.0.12 and on the latest
dev(fa1260d6).Setup: a local MCP server is started through a wrapper that launches the real server as a child, the way
npx/npm execdoes. The server keeps running after its stdin closes.Results:
serve --service) is SIGKILLed, the wrapper is reparented to PID 1 and its child keeps running, which matches the orphan pairs you saw.opencode service stopand SIGTERM clean up both processes correctly, on 2.0.12 and ondev.When it stops or replaces the background service, the client sends SIGTERM and then SIGKILL if the service hasn't exited within a few seconds. So a slow shutdown, or a restart of an unresponsive service, ends in exactly this case.
#59411 (merged, in 2.2.0) covers several of the other exit paths. It does not cover a SIGKILL of opencode itself, and that part is still open. The script below reproduces it: run it with
KILLorTERMto compare.I could only test on Linux, not macOS or the desktop app. The other part of your report, servers piling up per session while the service is still running, is tracked in #51249 and #51003.
Related:
Reproduction script (repro.sh)
Run it:
bash repro.shvictorarsenie commentedon Nov 5, 2026
Confirming on 2.1.x, Windows 11 (build 26300), service mode. This is not limited to
npx-launched servers or to the launcher tree in #50509.Both servers here are plain
python.exe <path>/server.pylocal MCP entries (ai-memory, websearch). Observed today:All four are children of a single
opencode-cliprocess (pid 18900, started 31/10 09:02). Restarting the desktop app did NOT clear the 04/11 pair: the service process survived the restart, kept the old pipes open, and spawned a new pair alongside it. So the user-visible workaround ("restart the app") does nothing.Two details that may help localise it:
for line in sys.stdin:is their whole main loop), so the orphan is holding a pipe the service never closed rather than being stuck.tools/callwith the code it imported at spawn time, so after an upgrade the stale instance serves older behaviour than the new session expects. That is how we noticed.#50509 was closed unmerged. Was
terminateTree()folded into another release path, or is a new PR needed?mlimarenko commentedon Nov 5, 2026
@victorarsenie #50509 was closed unmerged after
devmoved to V2; its author explicitly asked for it to be closed because it targeted the old package layout (explanation). It did not ship as that PR.A different cleanup change, #59411, landed in 2.2.0. It kills tracked process groups on certain OpenCode exit paths, but its Windows branch is a no-op. It is not evidence that this direct-
python.exe, still-running Windows service case is fixed.Your report also differs from the SIGKILL/PID-1 reproduction above: the original service remains alive, and restarting Desktop leaves that owner in place. Multiple loaded Locations can legitimately own separate server sets, so the extra pair alone does not prove that a disposed connection leaked. The stale imported code is a useful additional symptom.
Before another process-tree PR, could you check a current 2.2.x build and capture the service PID, loaded Locations (
/api/debug/location), and the corresponding MCP PIDs before and after the triggering reload/disconnect? The deciding detail is whether the old pair still belongs to a live Location or survives that Location's disposal. #50758 is the related reload/restart report; #37844 tracks retained per-Location fleets. I have not reproduced this Windows case, and #53046 does not claim to fix it.