fix(mcp): pre-install MCP servers globally to skip npx cache contention - #2418
Merged
Conversation
When Zed and Claude Code spawn in parallel against the shared `_npx/<hash>`
cache, npm's "reify mark retired" dance (rename node_modules/<pkg> →
.<pkg>-<rand>, reinstall, rename back) races and the MCP server's
JSON-RPC `initialize` never returns. Spec-task containers then surface
ERROR [project::context_server_store] chrome-devtools context server
failed to start: Context server request timeout
after the 180s `context_server_timeout` fires for chrome-devtools and
github (drone-ci was unaffected because it was already globally installed
at /usr/bin/drone-ci-mcp, so npm exec resolved via PATH and skipped the
cache entirely).
Manual reproduction with the exact same protocol version, env vars and
shell wrapping Zed uses returns initialize in <2s, confirming the hang
is in the npx install path, not the MCP server itself.
Fix:
- Dockerfile.ubuntu-helix: pin and globally install `chrome-devtools-mcp`
and `@modelcontextprotocol/server-github` next to the existing global
drone-ci-mcp install.
- zed_config.go: invoke `/usr/bin/chrome-devtools-mcp` directly instead
of `npx chrome-devtools-mcp@latest` so the cache is never touched.
- simple_sample_projects.go: switch the Helix-in-Helix sample's GitHub
MCP from `npx -y @modelcontextprotocol/server-github` to the global
`mcp-server-github` binary for the same reason.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…nd docs Same root cause as the previous commit: `npx -y <pkg>` for github and drone-ci spawns into the shared `_npx/<hash>` cache and races against the parallel chrome-devtools spawn from Zed/Claude Code, causing the JSON-RPC `initialize` to hang past the 180s timeout. The previous commit fixed the server-side defaults (zed_config.go hardcoded chrome-devtools entry, simple_sample_projects.go GitHub sample). But the actual config that lands in user projects comes from the frontend skill dialogs whenever a user enables a skill in Project Settings — and those were still hardcoded to `npx -y …`. Result: every project with the GitHub skill enabled keeps writing the broken config back into the project row, even on a freshly-fixed backend. Fix the same set of frontend hardcoded sites and the doc/example YAML: - `GitHubMcpSkill.tsx` (clicking "Enable" on the GitHub skill): command: 'mcp-server-github', args: []. - `AddLocalMcpSkillDialog.tsx` (the "Drone CI" example chip and the command-line placeholder): drone-ci-mcp instead of npx -y drone-ci-mcp. - `Skills.tsx`: tweak the "New Local MCP" skill description to reflect the global-binary pattern. - `examples/project.yaml`, `docs/helix-apply.md`: switch the GitHub MCP example from npx to the global binary. Existing user projects that already have the broken `npx -y …` config will continue to use it until the user re-saves the skill in Project Settings — that's user data and we can't migrate it from here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds /usr/local/bin/npx shim that gives each invocation its own NPM_CONFIG_CACHE, avoiding the npm `_npx/<hash>` "reify mark retired" rename race when multiple npx invocations target the same package in parallel. Caveat: Zed prepends its OWN bundled `~/.local/share/zed/node/.../bin` to PATH before /usr/local/bin when spawning context_servers and the Claude ACP wrapper, so this shim is bypassed for Zed-launched MCPs. The shim still helps for: - User-typed `command: "npx"` MCPs spawned from a non-Zed shell context (e.g. helix CLI, terminal sessions inside the dev container). - Defense-in-depth if/when Zed's PATH ordering changes. A complete fix needs to also shim Zed's bundled npx after it gets downloaded — TODO follow-up. The deeper fix is to remove the parallel spawn entirely (helixml/zed duplicate Claude ACP spawn) so the cache is never contended in the first place. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tion Captures the full diagnosis from the live debugging session: - The 180s `chrome-devtools/github context server failed to start: Context server request timeout` symptom is npm's `_npx/<hash>` rename race when multiple `npx <pkg>` invocations target the same package against the same cache directory in parallel. - Each container restart of a long-running spec task can spawn up to 3 Claude ACP sessions: Path A (Zed workspace restore), Path B (Helix open_thread), Path C (agent panel's draft thread). Each Claude independently spawns 5 MCP children from its --mcp-config — worst case 20 concurrent npm execs against the shared _npx cache. - The phantom "New Chat" sessions accumulating in the DB (spt_01kqc4ev5rt9rknk6g8dbkzj9a has 10, of which 8 have zero interactions) come from Path C: conversation_view.rs:1336 fires UserCreatedThread for any non-resume new_session, including the panel's permanent empty-input draft. Helix's handleUserCreatedThread duly records it as a real session. - Whether the bug bites a particular container is timing-dependent (WS connect vs panel restoration ordering — fresh containers "got lucky" because the WS wasn't ready when the draft tried to send the event). - PR #2418 partially fixes the npx-cache-contention symptom (global installs + binary-path config + per-spawn cache shim). The deeper fixes for the spawn-multiplication itself are queued in this doc: Fix 1 (Zed: defer UserCreatedThread until first user message), Fix 2 (Helix: dedup guard in handleUserCreatedThread), Fix 3 (HELIX_ACP_THREAD_ID env passthrough), Fix 4 (multiplex MCPs through ACP — long-term). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The original Fix 1 conflated two concerns: suppressing the UserCreatedThread WS event (which only stops Helix from creating the phantom session row) and stopping the eager new_session() call (which is what actually spawns the extra Claude process). These are layered: - Fix 1a: suppress UserCreatedThread for the panel's draft thread. Small change in helixml/zed. Stops phantom-Helix-session accumulation. Does NOT stop Claude spawn — the new_session() call at conversation_view.rs:1214 runs eagerly inside the load_task and the UserCreatedThread emission happens AFTER it returns successfully. - Fix 1b: lazily call new_session() for the draft. Bigger change — defer the load_task's new_session() call until the user actually submits a message. Stops the extra Claude spawn (and its 5 MCP children). Probably needs upstream Zed discussion since the "draft thread is always connected" assumption exists in upstream code too. Updated recommended order accordingly: 1a+2 land together for the immediate symptom, 1b follows as the structural fix. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two layered fixes for the spec-task phantom-session-accumulation bug (see design/2026-05-13-mcp-cache-contention-and-duplicate-claude-spawn.md for full diagnosis): Fix 1a (helixml/zed#TBD, Zed commit 32a1e3ba30): Zed defers UserCreatedThread until first user message in a draft thread. Stops the agent panel's speculative draft (the empty input editor's backing ConversationView) from being recorded as a real Helix session every container restart. Fix 2 (this commit, websocket_external_agent_sync.go): defensive guard in handleUserCreatedThread — if the spec_task already has an active work_session whose helix_session has no interactions, refuse to create a new one. Belt-and-braces against old Zed binaries in long-lived containers that haven't picked up Fix 1a. Bump ZED_COMMIT to pin Fix 1a in CI sandbox builds. Update design doc to reflect Fix 1b deferral with reasoning. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Merged
5 tasks
Adds two test cases for handleUserCreatedThread that prove Fix 2 is correctly wired: - TestUserCreatedThread_PhantomDraftGuard_RefusesWhenEmptyWorkSessionExists: spec_task already has an active work_session whose helix_session has zero interactions. The handler must short-circuit BEFORE calling CreateSession / CreateSpecTaskWorkSession / CreateSpecTaskZedThread. Verified to FAIL when the PHANTOM-DRAFT GUARD block in websocket_external_agent_sync.go is removed (gomock surfaces "Unexpected call to *store.MockStore.CreateSession", which is precisely the regression signal we want). - TestUserCreatedThread_PhantomDraftGuard_AllowsWhenExistingSessionHasInteractions: positive control — when the existing work_session HAS interactions, the guard does not fire and the normal create path runs. Also adds the new ListSpecTaskZedThreads mock expectation to the existing TestUserCreatedThread_CreatesWorkSessionForSpectask so it keeps passing under the new code path (CI build #1395 was failing because the existing test wasn't aware of the new ListSpecTaskZedThreads call introduced by the guard). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…erCreatedThread Pins helixml/zed@455c095fcc which adds the regression tests for the defer/flush/drop pending-emit machinery introduced in 32a1e3ba30.
Pins helixml/zed@056fe07180 which adds the end-of-round e2e assertion for the deferred-emit fix.
Bumps ZED_COMMIT to 769a463a2f which adds: - Fix 1b: AgentPanel::ensure_thread_initialized no longer calls activate_draft when external_websocket_sync feature is enabled. E2E-verified: 1 Claude process per fresh spec-task container, down from 2. - e2e Phase 17: counts live `claude --output-format` processes via ps in the test container and asserts the count == real threads created. Catches future regressions if anything reintroduces speculative Claude spawning. - e2e Phase 15 streaming-cadence assertion rewritten to be agent-agnostic: now checks "no more than 90% of final content arrives in the LAST 20% of stream time" instead of the midpoint-based check. The midpoint check false-failed for Claude Code's "thinking-then-burst" streaming pattern. The new assertion catches the actual regression signal (everything-arrives-in- Stopped-burst) without false positives on legitimate non-linear streaming. Updates the design doc to reflect that Fix 1b shipped (instead of being deferred) and the remaining roadmap. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Re-pin from the branch commit (769a463a2f) to the post-merge commit on main (62cd60aacabfa22d401c9e951edf922c84fe53d9). Same code, just the merge commit so future-bisects against main are clean.
4 tasks
lukemarsden
added a commit
that referenced
this pull request
May 13, 2026
When Helix changes the hardcoded definition of a Helix-owned context_server (e.g. PR #2418 switched chrome-devtools from `npx chrome-devtools-mcp@latest` to `/usr/bin/chrome-devtools-mcp`), the daemon's deep-merge in mergeSettings was treating the on-disk OLD entry as a "user override" and letting it win. This pinned the broken `npx` config in long-running containers' persisted settings.json forever and re-produced the 180s `chrome-devtools context server failed to start: Context server request timeout` errors that PR #2418 was meant to fix — even on containers running the new image and new API binary. Bug observed in https://meta.helix.ml/orgs/helix/projects/prj_01kg02vqqyg178c1n2ydscn5fb/tasks/spt_01kqc4ev5rt9rknk6g8dbkzj9a shortly after PR #2418 merged: chrome-devtools / drone-ci / github all showed "Context server request timeout" in Zed, despite the container running helix-ubuntu:6de75e (built post-merge with the new global MCP binaries) and helix-api running the new zed_config.go. Fix: introduce HELIX_OWNED_CONTEXT_SERVERS = {chrome-devtools, helix-session, helix-desktop} — the set of context_server names hardcoded in api/pkg/external-agent/zed_config.go. Two corresponding behavior changes in api/cmd/settings-sync-daemon/main.go: 1. mergeSettings: skip user-side context_server entries whose name is in HELIX_OWNED_CONTEXT_SERVERS so Helix's hardcoded definition unconditionally wins. Also strip helix-owned names from the "user-only" branch so a stale on-disk entry can't survive even when the API temporarily emits no context_servers. 2. extractUserOverrides: never capture helix-owned names as user overrides (otherwise the stale entry would round-trip back to the API and force the next sync to re-write the OLD value to disk, permanently nullifying the force-overwrite from #1). User-configured MCPs (e.g. drone-ci, github, custom servers from project skills or app config) are NOT in the helix-owned set — those legitimately can be edited by the user in their on-disk settings.json and must round-trip. Tests: - TestMergeSettings_HelixOwnedContextServersWin (4 sub-tests): force-overwrite chrome-devtools and helix-session when user has stale entries, allow user-configured drone-ci to win, strip helix-owned names even when helix has no servers. - TestExtractUserOverrides_SkipsHelixOwnedContextServers (2 sub-tests): stale on-disk helix-owned entries are not captured as user overrides; non-helix user overrides still round-trip. All sub-tests verified to FAIL when both guards are commented out (by replacing `if HELIX_OWNED_CONTEXT_SERVERS[name] {` with `if false && HELIX_OWNED_CONTEXT_SERVERS[name] {` and re-running). Full diagnosis: design/2026-05-13-mcp-cache-contention-and-duplicate-claude-spawn.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Spec-task containers were consistently failing to register
chrome-devtoolsandgithubMCP servers in Zed, surfacing as 180schrome-devtools/github context server failed to start: Context server request timeouterrors andmcp__chrome-devtools__*/mcp__github__*returningError: No such tool availablefrom agents.Investigation traced this to TWO compounding root causes — npm
_npx/<hash>cache contention from parallel npx spawns, and Zed's agent panel speculatively spawning a "draft" Claude ACP child on every container restart that doubled the cache contention.Full diagnosis in
design/2026-05-13-mcp-cache-contention-and-duplicate-claude-spawn.md.Root cause
When two
npx <pkg>invocations target the same package against the same npm cache directory in parallel, npm's "reify mark retired" rename dance (renamenode_modules/<pkg>→.<pkg>-<rand>, reinstall, rename back) races and the spawned MCP server's JSON-RPCinitializenever returns. Long-running spec_tasks made this much worse because every container restart spawned an extra "draft" Claude with its own MCP children, all racing for the same cache. The user's example taskspt_01kqc4ev5rt9rknk6g8dbkzj9ahad accumulated 10 phantom helix_session rows (8 with zero interactions) from this pattern.Manual reproduction with the exact protocol version, env vars and shell wrapping Zed uses returns
initializein <2s — the hang is purely in the parallel npm cache install path, not the MCP protocol or server.Fixes (across this PR + helixml/zed PR #56)
Bottom layer: stop the npm cache race
Dockerfile.ubuntu-helix— pin and globally installchrome-devtools-mcp@0.25.0and@modelcontextprotocol/server-github@2025.4.8next to the existing globaldrone-ci-mcp. When the binary is in PATH,npm exec <pkg>resolves it directly and skips_npx.api/pkg/external-agent/zed_config.go— chrome-devtools uses/usr/bin/chrome-devtools-mcpdirectly instead ofnpx chrome-devtools-mcp@latest.desktop/shared/helix-npx.sh+ Dockerfile —/usr/local/bin/npxshim that gives each invocation its ownNPM_CONFIG_CACHEso user-provided npx-based MCPs don't race either.Frontend / sample / doc cleanup (so future projects don't reproduce the bug)
frontend/src/components/app/GitHubMcpSkill.tsx— clicking "Enable" on the GitHub skill now writescommand: 'mcp-server-github'instead ofcommand: 'npx', args: ['-y', '@modelcontextprotocol/server-github'].frontend/src/components/app/AddLocalMcpSkillDialog.tsx— the "Drone CI" example chip and command-line placeholder now usedrone-ci-mcpdirectly.frontend/src/components/app/Skills.tsx— refreshed description text.api/pkg/server/simple_sample_projects.go— Helix-in-Helix sample's GitHub MCP usesmcp-server-githubdirectly.examples/project.yaml+docs/helix-apply.md— same change in YAML/docs examples.Top layer: stop the duplicate Claude spawn that amplified contention
Two layered fixes shipped via Zed:
helixml/zed#56, defersUserCreatedThread): the panel's draft thread no longer emitsUserCreatedThreadto Helix on creation. Insteaddefer_user_created_thread()registers it in a pending map; the existingensure_thread_subscriptionNewEntryhandler flushes the emit on the first user-role entry. Drafts the user never types in are never announced.helixml/zed#56, suppresses speculative draft activation): under theexternal_websocket_synccargo feature,AgentPanel::ensure_thread_initializedno longer callsactivate_draftonPanel::set_active(true). Helix drives all real conversations through thechat_messageWebSocket path (which goes viacreate_new_thread_sync, NOTactivate_draft), so spec-task functionality is unaffected. E2E-verified: 1 Claude process per fresh spec-task container, down from 2.Helix-side defensive guard (Fix 2)
api/pkg/server/websocket_external_agent_sync.go::handleUserCreatedThread— refuses to create a newhelix_sessionwhen the spec_task already has an activework_sessionwhosehelix_sessionhas zero interactions. Belt-and-braces against old Zed binaries in long-lived containers.Tests
api/pkg/server/websocket_external_agent_sync_test.go— 2 new tests:TestUserCreatedThread_PhantomDraftGuard_RefusesWhenEmptyWorkSessionExists(verified to FAIL when the guard is removed — gomock surfaces "Unexpected call to CreateSession") andTestUserCreatedThread_PhantomDraftGuard_AllowsWhenExistingSessionHasInteractions(positive control).helixml/zed#56) — 6 tests for the defer/flush/drop API inthread_service.rs.helixml/zed#56) — asserts 0 spontaneoususer_created_threadevents arrive per round (catches Fix 1a regression).helixml/zed#56) — counts liveclaude --output-formatprocesses viapsand asserts the count equals real threads created in the round (catches Fix 1b regression).helixml/zed#56) — the original "30% of content by midpoint" assertion false-failed for Claude Code's "thinking-then-burst" streaming pattern. Replaced with "no more than 90% of final content arrives in the LAST 20% of stream time" — agent-agnostic, catches the actual everything-arrives-at-end bug pattern, tolerates legitimate non-linear streaming.Bumps
sandbox-versions.txt→ZED_COMMIT=769a463a2fto pin all of the above.Test plan
./stack build-ubuntuproduces image with all 3 MCPs at/usr/bin/{chrome-devtools-mcp,mcp-server-github,drone-ci-mcp}+helix-npxshim at/usr/local/bin/npx.CGO_ENABLED=1 go test -v -run "TestWebSocketSyncSuite/TestUserCreatedThread" ./api/pkg/server/ -count=1— all 7 UserCreatedThread tests pass.cd frontend && yarn buildclean.📌 Deferring UserCreatedThread for <draft_uuid>for the panel's draft (Fix 1a working).spec_task_zed_threadsfor the test task (was 2 pre-fix).mcp__chrome-devtools__list_pagesreturnedabout:blank [selected],mcp__github__list_pull_requestsreturned PR fix(mcp): pre-install MCP servers globally to skip npx cache contention #2418 data (no MCP timeouts).Related
design/2026-05-13-mcp-cache-contention-and-duplicate-claude-spawn.mdFollow-ups (out of scope for this PR)
Existing user projects whose project-level MCP config was set up via the old
GitHubMcpSkill.tsx(writingcommand: 'npx', args: ['-y', '@modelcontextprotocol/server-github']) will keep that bad config in their project DB row until the user re-saves the skill in Project Settings. New project creation uses the fixed config from this PR.🤖 Generated with Claude Code