Skip to content

Stale stdio workers persist across plugin updates → version drift between hub and worker (potential wire-protocol crashes) #1090

@dsarno

Description

@dsarno

TL;DR

When the plugin is set to HTTP mode but a client like Claude Desktop is registered with a stdio-server entry (the current ClaudeDesktopConfigurator strategy), the long-lived stdio worker that Desktop spawns is never reaped or version-synced when:

  • the user updates com.coplaydev.unity-mcp via UPM, or
  • a new mcpforunityserver ships on PyPI.

The HTTP hub on :8080 happily accepts and routes to the stale worker. Result: the hub is on a newer version than the worker that's actually answering tool calls. Reproducible 1:1 on macOS today (caught it bisecting a "why am I not on the latest server" question).

Reproduction

Macbook, plugin v9.6.7-beta running, Claude Desktop and Claude Code both configured against the same plugin instance.

  1. PyPI publishes a new mcpforunityserver version (here: 9.6.8, uploaded 2026-04-27 01:31 UTC).
  2. User clicks Update in UPM → bridge restarts → fresh uvx --refresh resolves to 9.6.8 for the new HTTP server (PID 13418, started 08:29).
  3. Claude Desktop's stdio worker (spawned days earlier by Claude.app/Contents/Helpers/disclaimer → uvx ... mcp-for-unity --transport stdio) is still alive on 9.6.7b20260415190332 (the version when Desktop launched).
  4. From a Claude Code (HTTP) session, debug_request_context reports the stdio worker's version (9.6.7-beta), not the hub's. plugin_hub_configured: true is in the response.
  5. kill -TERM <stdio-worker-pid> → Claude Desktop's helper respawns the worker through uvx --refresh → new worker reports 9.6.8. Confirmed end-to-end: the version flip is only gated on the worker process restarting, nothing else.

Process tree before kill

PID    Started       Command
13418  Mon 08:29 AM  mcp-for-unity --transport http --http-url http://127.0.0.1:8080  (fresh, post-UPM)
24879  Sat 10:00 AM  mcp-for-unity --transport stdio                                    (zombie, 9.6.7-beta, serves tool calls)
24775  Sat 10:00 AM  mcp-for-unity --transport stdio                                    (second zombie)

After kill -TERM 24879 24775:

13418  Mon 08:29 AM  mcp-for-unity --transport http  (unchanged)
41889  Mon 08:38 AM  mcp-for-unity --transport stdio (fresh, 9.6.8) ← respawned by Claude Desktop's helper

Diff in debug_request_context output (same Claude Code session, no client reconnect)

before kill after kill
server.version 9.6.7b20260415190332 9.6.8
cache dir .tmphHxKLi/.../sgd8tdyzVdU0Q7A2ubTtD/ .tmprCkVS0/.../yWhUfm2IlE787g9Fe8mbI/
session_id 389dcdc0-… c8ac1a51-…

Root cause(s) — three layered

  1. Stdio-server config for clients that could be HTTP-bridged. ClaudeDesktopConfigurator.cs sets SupportsHttpTransport = false and throws if HTTP mode is active:

    throw new InvalidOperationException("Claude Desktop does not support HTTP transport. Switch to stdio in settings before configuring.");

    So a user who runs the plugin in HTTP mode and also wants Claude Desktop has to either (a) drop the plugin to stdio mode globally, or (b) accept this dual-mode setup where Desktop spawns its own server-as-worker. It's option (b) that opens the zombie window.

  2. No reaping of orphan workers on plugin lifecycle events. Domain reload, package update, HTTP server restart — none of these touch the workers spawned by client helper processes. The pidfile pattern at Library/MCPForUnity/RunState/mcp_http_8080.pid exists for the HTTP server but isn't extended to track stdio workers spawned via uvx.

  3. Hub doesn't refuse version-mismatched workers. When a stdio worker registers with the hub at :8080, no handshake compares its version against the hub's. So a 9.6.7 worker silently registers with a 9.6.8 hub. If/when wire format drifts even slightly between minor releases, this is a crash vector.

Suggested fixes (in increasing scope)

A. Quick win — bridge Claude Desktop through mcp-proxy, like godot-ai does

This is the structural fix. godot-ai's plugin (same author, FYI) handles every stdio-only client by writing a thin uvx mcp-proxy==<pinned> --transport streamablehttp <http-url> bridge entry instead of launching the whole server as stdio. There's only ever one server process. Reference:

  • clients/claude_desktop.gd — entry builder uses McpClient.mcp_proxy_bridge_args(url).
  • clients/_base.gd — pins MCP_PROXY_VERSION = "0.11.0" (cache-key reproducibility, vetted release floor).
  • clients/zed.gd — same pattern for Zed (also stdio-only).

For Unity-MCP that means:

  • Drop the throw in ClaudeDesktopConfigurator.Configure() when useHttp == true.
  • Emit:
    {
      "command": "<resolved-uvx-path>",
      "args": ["mcp-proxy==0.11.0", "--transport", "streamablehttp", "http://127.0.0.1:8080/mcp"]
    }
  • Resolve uvx to an absolute path (GUI apps run with minimal PATHuvx bare-name fails; godot-ai's McpCliFinder tier walk is the prior art).
  • Add a Status.CONFIGURED_MISMATCH state for when the saved entry's URL is stale (port changed, etc.) so the dock can show "your saved client URLs are stale" instead of conflating it with NOT_CONFIGURED. godot-ai's _base.gd:Status enum.

B. Reap orphan workers on update / domain reload

Even after (A), defense in depth: track every PID the bridge has ever caused to be spawned (via launchd-style handoff or just by scraping ps for mcp-for-unity children of disclaimer/uvx ancestors). On AssemblyReloadEvents.beforeAssemblyReload or package update, send SIGTERM to all of them, then wait for the client helper to respawn them fresh with --refresh.

C. Version handshake at hub registration

Have the hub at :8080 reject worker registrations whose mcpforunityserver version doesn't match its own (or at least log a loud warning). Cheap to do — the worker already exposes server.version via debug_request_context. If a mismatch is detected, the hub could SIGTERM the worker so the client helper respawns it.

Why this matters

The user-visible failure mode is "I updated the package, why am I still on the old version?" — confusing but cosmetic in the happy path. The real risk is silent wire-protocol drift between hub and stdio worker on minor-version bumps that change argument shapes. We've already had several 9.6.7bYYYYMMDDHHMMSS betas this month; the surface area for a breaking change to slip in across one of those boundaries is real.

Environment

  • macOS Darwin 25.4.0
  • Unity 6 (6000.3.9f1)
  • Plugin: v9.6.9-beta.1 in editor (per dock screenshot), HTTP transport on 127.0.0.1:8080
  • Claude Desktop entry in ~/Library/Application Support/Claude/claude_desktop_config.json:
    "unityMCP": {
      "command": "/Users/davidsarno/.local/bin/uvx",
      "args": ["--no-cache","--refresh","--prerelease","explicit",
               "--from","mcpforunityserver>=0.0.0a0",
               "mcp-for-unity","--transport","stdio"]
    }
  • Claude Code entry in ~/.claude.json for the project: {"type": "http", "url": "http://127.0.0.1:8080/mcp"} (correct, HTTP).

Metadata

Metadata

Assignees

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