Skip to content

mcp: local npx MCP servers leave orphaned node processes on Windows across restarts #59849

Open

Description

@konopka

Description

On Windows, local stdio MCP servers started via npx accumulate orphaned node.exe processes across opencode TUI/service restarts. Each restart leaves the previous generation's MCP process tree alive; nothing reaps them.

Root cause: the descendant-kill cleanup in packages/opencode/src/mcp/index.ts (descendants()) returns an empty array on win32, and the pgrep-based cleanup merged in #15516 explicitly skips Windows because pgrep is unavailable. client.close() only closes the MCP protocol connection, not the OS process tree.

Plugins

None

OpenCode version

2.2.0 (latest)

Steps to reproduce

  1. Configure a local MCP server launched via npx (e.g. npx -y @agentmemory/mcp).
  2. Start the opencode TUI and restart the service a few times (or let instances be disposed/recreated).
  3. List MCP node processes: Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Select ProcessId, CreationDate, CommandLine.
  4. Observe multiple generations (an npx wrapper + server per configured MCP, per generation) instead of one.

Screenshot

Process list after ~1 day of TUI use — 12 node.exe processes across 4 restart generations (07:12, 07:17, 07:41, 07:49 local). Each configured server leaves a pair (npx wrapper + server); only the newest generation should exist:

PID     Created (local)   Role
29092   07:49:07          npx wrapper (current generation)
37384   07:49:07          npx wrapper (current generation)
46392   07:49:14          @agentmemory/mcp (current generation)
+ 8 stale processes from the 07:12 / 07:17 / 07:41 generations (manually taskkilled)

Operating System

Windows 11 (10.0.26300, x64)

Terminal

Windows Terminal


Additional context:

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report and the process listing.

    Our reproduction sandbox runs Linux, so we can't test how OpenCode cleans up processes on Windows. A maintainer with a Windows machine will need to confirm this.

    Here is what we found in the code, on 2.2.0 and the current dev (fa1260d):

    • The file paths in the report (packages/opencode/src/mcp/index.ts, descendants(), the pgrep cleanup) are from v1. In v2, local MCP servers are started by packages/core/src/mcp/stdio.ts through the shared spawner in packages/util/src/cross-spawn-spawner.ts.
    • On a clean shutdown, that spawner runs taskkill /pid <pid> /T /F on Windows, which should kill the whole tree.
    • If the service exits without running its cleanup (a crash, a forced kill, a watchdog restart), we didn't find anything that reaps MCP processes on Windows. The kill-on-exit hook added in fix(util): kill spawned process groups when opencode exits #59411 is skipped on Windows, and there is no job object with kill-on-close. That matches what you're seeing.

    It would help to know whether the extra generations also appear after a clean quit of the TUI and service, or only after restarts that might be forced or caused by a crash (for example the watchdog restart in #52049).

    Related issues:

  2. konopka commented on Oct 28, 2026

    @konopka
    Author

    Both — the extra generations appear without any TUI or service restart at all; they accumulate through instance creation/disposal within the long-running shared service.

    Evidence, v2.2.0, Windows 11:

    1. The shared service (opencode.exe serve --service) ran continuously since Oct 27 16:34 (the TUI since Oct 26 23:03), yet the MCP process generations spawned at 07:12 / 07:17 / 07:41 / 07:49 local were all children of that same service process. So no crash or forced restart is involved — the stale trees are a byproduct of in-service instance/MCP re-creation.

    2. Minimal repro with no restart of anything:

      • Temp dir with a throwaway local MCP server (node testorphan.mjs, a minimal stdio MCP server that stays alive while stdin is open), defined in a project-level opencode.json.
      • Ran opencode run "..." in that dir. It attached to the already-running shared service and created a directory instance, which spawned 5 node processes: the throwaway server plus 4 from global MCP servers that were re-spawned for the new instance (npx wrapper + server per MCP).
      • opencode run exited cleanly and the instance was disposed. All 5 child processes were still alive 30+ seconds later — nothing reaped them.

    So the missing reap is in the per-instance MCP dispose path on Windows, and it fires during normal use — no shutdown of any kind required. (Untested: whether a full clean shutdown of the service process itself reaps its own children; given the accumulation above, that's moot in practice.)

    Process topology at the time (abridged):

    opencode.exe (TUI, Oct 26 23:03)
    └─ opencode.exe serve --service (Oct 27 16:34, still running)
       ├─ cmd → node (npx) → cmd → node   ← global MCP, Oct 28 07:49 generation
       ├─ cmd → node (npx) → cmd → node   ← global MCP, Oct 28 07:49 generation
       └─ node testorphan.mjs              ← throwaway MCP of the disposed instance,
                                             still alive after clean `opencode run` exit
    

    Side observation (may be a separate issue): the new instance re-spawned the global MCP servers even though the project-level config set enabled: false for them, while a project-only server in the same file was picked up. The config precedence docs say project configs override global ones for conflicting keys, but don't specify merge granularity for nested mcp entries — unclear whether this is a docs gap or a merge-order bug.

  3. konopka commented on Oct 28, 2026

    @konopka
    Author

    Update: reaping works on clean paths — the accumulation is retention, not a missing kill. (v2.2.0, Windows 11)

    Answer to your question: the extra generations appear in neither case. They accumulate during normal use while the service runs, and the kill path itself is functional:

    1. Reaping verified working. I pointed a test dir at a 30 s experimental.location_idle_timeout with a throwaway local MCP plus two global npx-based MCPs. On every idle-sweep eviction, the full MCP process tree (cmd → npx → cmd → server) was killed — verified with a throwaway server that ignores stdin-EOF and outlives its wrapper. Same result via manual eviction (DELETE /api/debug/location).

    2. Why stale generations accumulate anyway — two multiplicative factors:

    • Every directory-location boot spawns all configured MCP servers (one process pair per server).
    • Locations are retained 60 min by default (location_idle_timeout), refreshed by session activity, and occupied locations are never evicted (packages/core/src/location-activity.ts). The "generations" in my report were retained locations each holding a live MCP set — not unreaped orphans.

    3. Second bug found while investigating: an evicted location was re-requested 38–500 ms after every eviction, in an exact 2-minute cycle, surviving manual eviction and deletion of all its headless sessions. Each cycle fully re-boots the location (watchers, config, plugins, full MCP set) and is reaped next cycle — no unbounded growth, but permanent process churn. The only connected client was the TUI, so the stale reference likely sits in TUI session retention (or a zombie job from a deleted session). Logs available if useful.

    4. Related observation: two locations booting the same npx-based MCP concurrently — the second failed with EBUSY (npx cache copyfile lock). Per-location duplication can break connectivity, not just waste RAM.

    So the original "nothing reaps them" premise doesn't hold on v2.2.0. The true remaining gaps: (a) crash/force-kill of the service still orphans the tree (no Job Object / KILL_ON_JOB_CLOSE), (b) retention policy × per-location duplication is what produces the accumulation, (c) the Windows descendant-termination test is skipped and taskkill failures in the spawner are swallowed.

  4. xjstar commented on Nov 4, 2026

    @xjstar

    Windows 11 field data: 22 of 29 locations are opencode-generated temp dirs

    Adding measurements from a Windows 11 / opencode 2.2.0 host, since the sandbox can't cover this platform. They corroborate the retention + churn analysis in the update, and add one data point I haven't seen reported: the locations driving the accumulation are opencode's own temp directories, not user project dirs.

    Environment

    • opencode 2.2.0 (@opencode/cli), installed 2026-10-27
    • Windows 11 Enterprise 10.0.22621, x64
    • Shared service (opencode.exe serve --service) had been running continuously for 6 days
    • Local MCPs: codegraph (node), chrome-devtools-mcp (npx)

    Symptom

    95 node.exe processes / 8.0 GB, all parented to one live service process. Nothing was crashed or restarted — this accumulated during normal use, matching your "retention, not a missing kill" conclusion.

    Breakdown by MCP:

    chrome-devtools-mcp : 54 processes / 4200 MB
    codegraph           : 38 processes / 2279 MB
    

    Finding 1 — the locations are temp dirs, and they outnumber real projects 3:1

    Across all mcp connected log lines, 29 distinct locations. Only 7 are real working directories (a few repos plus a home dir). The other 22 are opencode-generated temp dirs under %LOCALAPPDATA%\Temp:

    ocime-*  ×5      oce-*   ×3
    ocw-*    ×4      ock-*   ×1
    occp-*   ×2      ocm-*   ×1
    ocb-*    ×2      ocsd-*  ×1
    ocp-*    ×1
    

    These 22 temp locations alone produced 87 MCP connections (15 of them × 5 connections, 6 of them × 2). Whatever creates these temp dirs is booting a full MCP set each time.

    I could not determine what each prefix is for — they're not literal strings in the binary, so they're assembled at runtime. Worth a look from someone who knows the code: a temp scratch dir arguably shouldn't be treated as a project location at all.

    Finding 2 — reproduced the re-request cycle, ~20–25 s period

    Sampling new chrome-devtools process creations over 180 s:

    11:52:42
    11:56:44  (+242s)
    11:56:49  (+5s)
    11:58:25  (+96s)
    11:58:29  (+4s)
    11:59:16  (+47s)
    11:59:21  (+5s)
    12:02:02  (+161s)
    12:02:06  (+5s)
    12:02:26  (+19s)
    12:02:30  (+5s)
    12:02:51  (+20s)
    12:02:56  (+6s)
    

    Each event is a pair ~5 s apart (the MCP's own wrapper + server). The oce-* series is the cleanest illustration — a brand-new location every ~25 s, each booting the full MCP set:

    04:02:02  oce-nzyplyx8    codegraph
    04:02:08  oce-nzyplyx8    chrome-devtools
    04:02:27  oce-id4sfv2i    codegraph
    04:02:32  oce-id4sfv2i    chrome-devtools
    04:02:51  oce-sfzyp276    codegraph
    04:02:58  oce-sfzyp276    chrome-devtools
    

    My period is ~20–25 s rather than the 2 min you measured, and it never settled even after clearing all oc* temp dirs. Only connected client was the TUI.

    Finding 3 — npx triples the per-location cost

    Switching from npx -y <pkg>@latest to invoking the already-installed package directly cut each MCP instance from 3 processes to 1:

    processes RSS
    npx -y chrome-devtools-mcp@latest node(npx-cli) + node(_npx/…) + node(.bin/…) 3 / ~233 MB
    node <global>/build/src/bin/chrome-devtools-mcp.js 1 1 / ~107 MB

    So the per-location multiplier is ~233 MB with npx vs ~107 MB direct — on top of the EBUSY npx-cache contention you noted when two locations start the same npx MCP concurrently. Worth calling out in docs as a recommendation even before the retention bug is fixed.

    Finding 4 — location_idle_timeout format is undocumented and easy to get wrong

    The schema accepts string | false, and the description says default: "60 minutes", but neither the docs nor the schema state the accepted format. I tested against the running server and read configuration normalization diagnostic warnings from the log:

    value result
    "15 minutes" ✅ accepted
    "15m" ❌ kind=invalid … skipped malformed recognized value
    "PT15M" ❌ rejected
    900000 ❌ rejected

    Only the prose-style "15 minutes" works. Since the failure mode is a silent skip (a WARN in the log, config otherwise loads fine), it's easy to think the setting took effect when it didn't. A schema pattern or a docs line would help.

    Confirmed after the fix: last configuration normalization diagnostic warning was at 05:56:47Z, config rewritten at 05:56:59Z, and no new warning since — so "15 minutes" is genuinely applied, not silently skipped.

    What's still unexplained on my end

    Even after (a) pointing chrome-devtools at the direct binary, (b) setting location_idle_timeout to "15 minutes", and (c) clearing all oc* temp dirs, I still see a slow trickle of new locations. Consistent with your gap (b): retention policy is the driver, and something keeps minting new temp locations. If it's useful I can attach the full mcp connected log slice with timestamps and directory names.

    Happy to run any instrumentation you'd like on Windows — that's the platform the sandbox can't reach.

  5. Darek3399 commented on Nov 12, 2026

    @Darek3399

    Yeah, client.close() on Windows is basically a polite suggestion when npx is involved. Because npx spawns through cmd.exe / npx.cmd before launching the actual node.exe worker, closing the stdio transport or killing the top wrapper PID leaves the grandchildren running orphaned. The only bulletproof way on Windows is assigning the spawned process to a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.

    Until OpenCode wires up Job Objects internally, a clean workaround is hosting your local stdio servers in MCP-Bridge and connecting OpenCode to Bridge's local HTTP endpoint. It already runs every managed server inside a Windows Job Object and checks for leftover port/PID locks, so restarting the TUI or toggling servers off leaves zero stray node.exe processes in Task Manager.

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