Skip to content

fix(mcp): pre-install MCP servers globally to skip npx cache contention - #2418

Merged
lukemarsden merged 11 commits into
mainfrom
fix/mcp-cache-contention
May 13, 2026
Merged

fix(mcp): pre-install MCP servers globally to skip npx cache contention#2418
lukemarsden merged 11 commits into
mainfrom
fix/mcp-cache-contention

Conversation

@lukemarsden

@lukemarsden lukemarsden commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Spec-task containers were consistently failing to register chrome-devtools and github MCP servers in Zed, surfacing as 180s chrome-devtools/github context server failed to start: Context server request timeout errors and mcp__chrome-devtools__* / mcp__github__* returning Error: No such tool available from 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 (rename node_modules/<pkg>.<pkg>-<rand>, reinstall, rename back) races and the spawned MCP server's JSON-RPC initialize never 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 task spt_01kqc4ev5rt9rknk6g8dbkzj9a had 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 initialize in <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 install chrome-devtools-mcp@0.25.0 and @modelcontextprotocol/server-github@2025.4.8 next to the existing global drone-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-mcp directly instead of npx chrome-devtools-mcp@latest.
  • desktop/shared/helix-npx.sh + Dockerfile/usr/local/bin/npx shim that gives each invocation its own NPM_CONFIG_CACHE so 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 writes command: 'mcp-server-github' instead of command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'].
  • frontend/src/components/app/AddLocalMcpSkillDialog.tsx — the "Drone CI" example chip and command-line placeholder now use drone-ci-mcp directly.
  • frontend/src/components/app/Skills.tsx — refreshed description text.
  • api/pkg/server/simple_sample_projects.go — Helix-in-Helix sample's GitHub MCP uses mcp-server-github directly.
  • 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:

  • Fix 1a (helixml/zed#56, defers UserCreatedThread): the panel's draft thread no longer emits UserCreatedThread to Helix on creation. Instead defer_user_created_thread() registers it in a pending map; the existing ensure_thread_subscription NewEntry handler flushes the emit on the first user-role entry. Drafts the user never types in are never announced.
  • Fix 1b (helixml/zed#56, suppresses speculative draft activation): under the external_websocket_sync cargo feature, AgentPanel::ensure_thread_initialized no longer calls activate_draft on Panel::set_active(true). Helix drives all real conversations through the chat_message WebSocket path (which goes via create_new_thread_sync, NOT activate_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 new helix_session when the spec_task already has an active work_session whose helix_session has 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") and TestUserCreatedThread_PhantomDraftGuard_AllowsWhenExistingSessionHasInteractions (positive control).
  • Zed unit tests (helixml/zed#56) — 6 tests for the defer/flush/drop API in thread_service.rs.
  • E2E Phase 16 (helixml/zed#56) — asserts 0 spontaneous user_created_thread events arrive per round (catches Fix 1a regression).
  • E2E Phase 17 (helixml/zed#56) — counts live claude --output-format processes via ps and asserts the count equals real threads created in the round (catches Fix 1b regression).
  • E2E Phase 15 rewrite (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.txtZED_COMMIT=769a463a2f to pin all of the above.

Test plan

  • ./stack build-ubuntu produces image with all 3 MCPs at /usr/bin/{chrome-devtools-mcp,mcp-server-github,drone-ci-mcp} + helix-npx shim at /usr/local/bin/npx.
  • CGO_ENABLED=1 go test -v -run "TestWebSocketSyncSuite/TestUserCreatedThread" ./api/pkg/server/ -count=1 — all 7 UserCreatedThread tests pass.
  • Verified the new phantom-draft tests FAIL when the guard is removed (regression-test power confirmed).
  • cd frontend && yarn build clean.
  • Started a fresh spec-task in inner-Helix with the new Zed binary:
    • Zed log shows 📌 Deferring UserCreatedThread for <draft_uuid> for the panel's draft (Fix 1a working).
    • Live process count: exactly 1 Claude alive (down from 2 — Fix 1b working).
    • DB: exactly 1 row in spec_task_zed_threads for the test task (was 2 pre-fix).
    • mcp__chrome-devtools__list_pages returned about:blank [selected], mcp__github__list_pull_requests returned PR fix(mcp): pre-install MCP servers globally to skip npx cache contention #2418 data (no MCP timeouts).
  • CI (Drone build feat(kodit): index internal repos #1401) — all unit + integration tests pass.

Related

Follow-ups (out of scope for this PR)

Existing user projects whose project-level MCP config was set up via the old GitHubMcpSkill.tsx (writing command: '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

lukemarsden and others added 6 commits May 12, 2026 17:06
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>
lukemarsden and others added 5 commits May 13, 2026 11:16
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.
@lukemarsden
lukemarsden merged commit 0073852 into main May 13, 2026
1 check passed
@lukemarsden
lukemarsden deleted the fix/mcp-cache-contention branch May 13, 2026 11:08
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant