Skip to content

plugin.json mcpServers.command pinned to process.execPath has no liveness guard — and the MCP server cannot self-heal it #1090

Description

@jeanmonet

Summary

normalizePluginJson() rewrites .claude-plugin/plugin.json mcpServers.command from the shipped "node" to process.execPath. On Homebrew (and any versioned Node manager) that is a version-pinned path. After the next Node upgrade the path is deleted and the MCP server stops connecting with a bare ENOENT:

ENOENT: no such file or directory, posix_spawn '/…/Cellar/node/<old>/bin/node'

The liveness guards added for the sibling cases do not apply here, and cannot: they run inside a live process at read time, but the process that would heal mcpServers.command is the MCP server itself — which is exactly what fails to spawn. There is no path back without editing the file by hand.

Why this one is not covered by the earlier fixes

The same class has been fixed four times, each for a different consumer:

mcpServers.command is the remaining one. It is also the only consumer whose failure is unrecoverable by self-heal, for the bootstrap reason above.

Where it happens

hooks/normalize-hooks.mjs:

if (srv.command === "node" && mutated) {
  srv.command = safeNode;   // safeNode derives from process.execPath
}

reached from normalizeHooksOnStartup(), which the comment marks as intentional:

// plugin.json rewrite: ALWAYS uses nodePath (MCP server must stay on Node, #543)

The intent behind #543 (keep the MCP server on Node, never Bun) is sound. The problem is only that it is implemented by pinning an absolute versioned path rather than by naming a Node that stays valid.

Reproduce

  1. Install the plugin with a Node whose process.execPath is version-pinned (Homebrew, mise, nvm, asdf).
  2. Confirm .claude-plugin/plugin.json now carries the absolute pinned interpreter instead of "node".
  3. Upgrade Node so the old version directory is removed (brew upgrade node && brew cleanup).
  4. Start a new session — the MCP server fails with ENOENT on the deleted path, and no self-heal runs.

Possible fixes

  1. Leave command as the shipped "node" and let PATH resolve it. upgrade: Native addon ABI cache missing when upgrade CLI runs under Bun runtime #543's requirement is "Node, not Bun", which bare node already satisfies.
  2. If an absolute path is wanted, resolve to a stable one rather than the versioned snapshot — e.g. the unversioned symlink a version manager maintains (<brew-prefix>/bin/node) — so it survives upgrades.
  3. Apply the write-time equivalent of the fix(runtime): add liveness guard to resolveJavascriptRuntime for Homebrew Cellar ENOENT (#800) #803 guard: refuse to persist an interpreter path under a version-pinned directory shape.

Option 1 or 2 also fixes it retroactively for installs already carrying a pinned path, since the rewrite is re-evaluated on the next successful boot — though only for users whose server can still start.

Note on scope

Because a manual edit is currently the only recovery, this may be worth treating as higher severity than the earlier three: an affected user sees only an ENOENT naming a Node path they never configured, with no indication that a plugin wrote it.

Activity

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions