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.
- PyPI publishes a new
mcpforunityserver version (here: 9.6.8, uploaded 2026-04-27 01:31 UTC).
- 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).
- 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).
- 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.
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
-
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.
-
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.
-
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 PATH — uvx 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).
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
ClaudeDesktopConfiguratorstrategy), the long-lived stdio worker that Desktop spawns is never reaped or version-synced when:com.coplaydev.unity-mcpvia UPM, ormcpforunityserverships on PyPI.The HTTP hub on
:8080happily 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.
mcpforunityserverversion (here:9.6.8, uploaded2026-04-27 01:31 UTC).uvx --refreshresolves to 9.6.8 for the new HTTP server (PID 13418, started08:29).Claude.app/Contents/Helpers/disclaimer → uvx ... mcp-for-unity --transport stdio) is still alive on 9.6.7b20260415190332 (the version when Desktop launched).debug_request_contextreports the stdio worker's version (9.6.7-beta), not the hub's.plugin_hub_configured: trueis in the response.kill -TERM <stdio-worker-pid>→ Claude Desktop's helper respawns the worker throughuvx --refresh→ new worker reports9.6.8. Confirmed end-to-end: the version flip is only gated on the worker process restarting, nothing else.Process tree before kill
After
kill -TERM 24879 24775:Diff in
debug_request_contextoutput (same Claude Code session, no client reconnect)server.version9.6.7b202604151903329.6.8.tmphHxKLi/.../sgd8tdyzVdU0Q7A2ubTtD/.tmprCkVS0/.../yWhUfm2IlE787g9Fe8mbI/session_id389dcdc0-…c8ac1a51-…Root cause(s) — three layered
Stdio-server config for clients that could be HTTP-bridged.
ClaudeDesktopConfigurator.cssetsSupportsHttpTransport = falseand throws if HTTP mode is active: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.
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.pidexists for the HTTP server but isn't extended to track stdio workers spawned via uvx.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 doesThis 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 usesMcpClient.mcp_proxy_bridge_args(url).clients/_base.gd— pinsMCP_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:
ClaudeDesktopConfigurator.Configure()whenuseHttp == true.{ "command": "<resolved-uvx-path>", "args": ["mcp-proxy==0.11.0", "--transport", "streamablehttp", "http://127.0.0.1:8080/mcp"] }uvxto an absolute path (GUI apps run with minimalPATH—uvxbare-name fails; godot-ai'sMcpCliFindertier walk is the prior art).Status.CONFIGURED_MISMATCHstate 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 withNOT_CONFIGURED. godot-ai's_base.gd:Statusenum.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
psformcp-for-unitychildren ofdisclaimer/uvxancestors). OnAssemblyReloadEvents.beforeAssemblyReloador package update, sendSIGTERMto 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
:8080reject worker registrations whosemcpforunityserverversion doesn't match its own (or at least log a loud warning). Cheap to do — the worker already exposesserver.versionviadebug_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.7bYYYYMMDDHHMMSSbetas this month; the surface area for a breaking change to slip in across one of those boundaries is real.Environment
127.0.0.1:8080~/Library/Application Support/Claude/claude_desktop_config.json:~/.claude.jsonfor the project:{"type": "http", "url": "http://127.0.0.1:8080/mcp"}(correct, HTTP).