Skip to content

mcp: orphaned local MCP server processes accumulate after sessions end #50363

Open

Description

@paween1980

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/cli 2.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/cli global package; desktop app /Applications/OpenCode.app)
  • Active plugins: none found (~/.config/opencode/opencode.jsonc; no project .opencode/ directory)

Reproduction

  1. Configure a local MCP server with an npx command 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
    }
  2. 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.

  3. 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 had PPID 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 1 orphan pairs (preserving all descendants of the live opencode.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 exec wrapper 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

  1. argszero commented on Sep 21, 2026

    @argszero
    Contributor

    I'd like to work on this.

    I read the current dev tree (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 runs pgrep -P before the SDK's close().
    • Every other teardown path in the same file calls client.close() alone (closeClient() at ~:562, used when a server is disabled/reloaded at ~:633/:656, and the replaced client in storeClient() at ~:587).
    • The SDK's StdioClientTransport.close() ends stdin, then SIGTERM, then SIGKILL — but only for its own direct child, not for that child's descendants. So npx -y <server> (wrapper + node …/.bin/<server>) is exactly the shape that survives a non-finalizer teardown.
    • Nothing in the layer covers the case where the spawning process itself goes away without running finalizers, which matches the PPID 1 pairs you observed after service restarts.

    So my plan is:

    1. Terminate the process tree in one helper that every teardown path uses (instance finalizer, closeClient(), and a replaced client in storeClient()), rather than only the finalizer doing the descendant sweep.
    2. Add a lifecycle test that spawns a stdio MCP server which itself spawns a child process, tears it down through the non-finalizer path, and asserts the whole tree is gone — the existing packages/opencode/test/mcp/lifecycle.test.ts harness and the pid-file fixture already give the seam for this.

    I'll open a PR against dev and link it here once the tests are green.

  2. argszero commented on Sep 22, 2026

    @argszero
    Contributor

    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 in storeClient(), and the failed connectTransport() release. The sweep runs before close() because pgrep -P can only see the children while the launcher is still alive.

    Measured against the unfixed code: with src/mcp/index.ts reverted, both new lifecycle tests fail and two orphaned child processes are left running, which is the leak in this report.

  3. HenT commented on Sep 27, 2026

    @HenT

    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.

  4. abhisheksharma611 commented on Oct 5, 2026

    @abhisheksharma611

    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, filesystem via npx, playwright via npx, scrapling, hyperresearch). One opencode.exe at ~1 hour uptime:

    codebase-memory fleets alive 12
    MCP-related processes 158
    Combined working set 6,445 MB
    Free RAM 1.1 GB of 15.7 GB

    All 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.spawn and node:child_process.spawn both 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 your npx wrapper surviving shutdown is entirely plausible and this says nothing against it.

    One code detail that may matter for your npx case

    In the MCP disposal path, descendants() in packages/opencode/src/mcp/index.ts:

    function* (pid: number) {
      if (process.platform === "win32") return [] as number[]
      // ...pgrep -P walk

    The Windows branch returns an empty list, so the tree walk is skipped there and only client.close() runs. On POSIX the pgrep -P walk 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.

  5. manosKas commented on Oct 7, 2026

    @manosKas

    Confirming on Linux / opencode v2.0.24 (service mode) with chrome-devtools-mcp spawned via npx -y chrome-devtools-mcp@latest — same lifecycle as described here.

    Measurements (today, before manual cleanup)

    7 orphaned chrome-devtools-mcp stacks, one spawned per session, all parented by the opencode serve --service daemon 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):

    component RSS
    npm exec chrome-devtools-mcp@latest ... (npx wrapper) ~90 MB
    chrome-devtools-mcp (node server) ~156 MB
    telemetry watchdog child (build/src/telemetry/watchdog/main.js) ~76 MB
    sh -c wrapper ~2 MB
    total ~320 MB

    Total leaked: ~2.2 GB, which read as "opencode occupies ~3 GB" in ps aux --sort=-rss until inspected.

    Workaround in place

    A systemd timer (OnUnitActiveSec=10min) running a small janitor script that kills chrome-devtools-mcp process 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

    • Consistent with the suspected cause in the OP: the npm exec wrapper layer keeps stdio open, so the server never observes EOF and the daemon-side teardown (or its absence) leaves the whole tree alive.
    • Watching fix(opencode): terminate the whole MCP server process tree on teardown #50509 — the terminateTree() approach on every release path looks like the right fix. Happy to verify against it once merged or in a canary build.
  6. oopen commented on Oct 12, 2026

    @oopen

    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)

    • 8 live process trios at once (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.
    • The managed service returned GET /api/session/active → {"data":{}} (no active session) while still holding a 108 MB playwright-mcp child spawned ~30 min earlier.
    • Each session connect logs 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 lone level=WARN message="mcp transport error" … error="MCP server process exited" was followed by a reconnect that spawned yet another process.
    • Two long-running servers on the host (manual opencode serve --hostname 0.0.0.0 --port 4096 + managed service) leak independently.
    • No browser process leaks — only the MCP server processes (browsers close correctly).

    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.

  7. AfftarN commented on Oct 12, 2026

    @AfftarN

    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/location returned 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?

  8. tonydzi commented on Oct 13, 2026

    @tonydzi

    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:

    • only servers with a url (HTTP/SSE) stay in the always-on config; a daemon owns the single process and sessions just connect
    • stdio servers live outside the config and are added on demand and removed afterwards
    • before dropping anything, count real calls from tool_use blocks 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 reminder

    So 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.

  9. VariableVince commented on Oct 17, 2026

    @VariableVince

    Confirming this problem is still present

  10. mlimarenko commented on Oct 18, 2026

    @mlimarenko
    Contributor

    @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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions