Skip to content

macOS: UtilityProcess rejects third-party native Node modules due to library validation (distinct from #180) #229

@ignaciohermosillacornejo

Description

macOS: UtilityProcess rejects third-party native Node modules due to library validation (distinct from #180)

Summary

On macOS, Claude Desktop runs Node-based MCPB extensions inside an Electron UtilityProcess that enforces hardened-runtime library validation. Any bundled native .node binary not signed with Anthropic's Team ID is rejected by dlopen() before the extension's JS even starts executing. This breaks every MCPB that depends on a prebuilt native module: classic-level, better-sqlite3, lmdb, sharp, sqlite3, node-pty, fsevents, and so on.

This is distinct from #180 (which is about Node.js ABI / NODE_MODULE_VERSION mismatch). Library validation rejects the binary even when the ABI matches — the check happens before ABI checks.

The failure

Error observed in ~/Library/Logs/Claude/main.log (Claude Desktop 1.2581.0, macOS):

[UtilityProcess stderr] [nodeHost] import-failed: dlopen(
  .../node_modules/classic-level/prebuilds/darwin-x64+arm64/classic-level.node,
  0x0001
): code signature in <...> not valid for use in process:
mapping process and mapped file (non-platform) have different Team IDs

The process exits during module load before any console.error in the extension's entry point fires, which is why users see "Server disconnected" with no diagnostic output in mcp-server-*.log.

Reproduction

Minimal case: any MCPB manifest with server.type: "node" and a native dep whose prebuild ships an ad-hoc / linker-signed binary (i.e., every npm package using node-gyp-build prebuilds).

{
  "server": {
    "type": "node",
    "entry_point": "dist/cli.js",
    "mcp_config": {
      "command": "node",
      "args": ["${__dirname}/dist/cli.js"]
    }
  }
}

Where dist/cli.js does import "classic-level" (or any package with a prebuilt .node). Installed .mcpb, launched inside Claude Desktop → fails as above. The exact same code run via node dist/cli.js from a terminal works fine, because system Node has no hardened runtime.

Real-world instance affecting end users in my project: ignaciohermosillacornejo/copilot-money-mcp#249.

Root cause

macOS hardened runtime requires every dynamically loaded library to share the host process's Team ID (Anthropic's is Q6L2SF6YDW) unless the host process carries the entitlement com.apple.security.cs.disable-library-validation.

Inspecting Claude.app's helper entitlements (codesign -d --entitlements -):

Helper disable-library-validation
Claude (main)
Helper (GPU)
Helper (Plugin)
Helper (Renderer)
Helper.app

Only "Helper (Plugin).app" carries the entitlement, but Claude Desktop's MCP UtilityProcess does not run inside it. Electron exposes a macOS-only flag on utilityProcess.fork(), allowLoadingUnsignedLibraries: true, which routes the forked process to the Plugin helper. Passing this flag — combined with ensuring the Plugin helper is signed with disable-library-validation — is the standard fix for plugin-hosting Electron apps.

Anthropic's own native modules (sharp-darwin-arm64.node, audio-capture.node, computer_use.node in app.asar.unpacked/) load fine because they're signed with Anthropic's certificate.

Prior art

  • LM Studio hit the identical bug in February 2026: lmstudio-ai/lmstudio-bug-tracker#1494. Root cause was the Plugin helper missing disable-library-validation. Fixed with a one-line entitlement addition.
  • VS Code, Cursor, and Obsidian all sign their plugin-hosting helpers with disable-library-validation and pass allowLoadingUnsignedLibraries: true from the main process. This is standard practice for Electron apps that load third-party native code.
  • The MCPB README already warns: "Limitation: Cannot portably bundle compiled dependencies." This issue is the macOS-specific mechanism behind that limitation; there is no documentation or official workaround for it today.

Suggested fix

  1. Sign Claude Helper (Plugin).app with an entitlements plist that includes com.apple.security.cs.disable-library-validation.
  2. In the Claude Desktop MCP host, call utilityProcess.fork(modulePath, args, { allowLoadingUnsignedLibraries: true, ... }) when launching Node-based MCPB extensions on macOS.

These two changes together allow third-party .node prebuilds to load without breaking anything else in the hardened runtime model (JIT restrictions etc. still apply to the main/renderer/GPU helpers).

Current workaround for extension authors

Until the entitlement is added, extensions can escape the UtilityProcess path by setting mcp_config.command to something other than the literal string "node" — e.g. /usr/bin/env with node prepended to args, scoped to darwin via platform_overrides. This causes the router in app.asar (.vite/build/index.js) to fall through to {type: "exec"}, which spawns the server as a plain child process of the Claude Desktop main process (no hardened runtime), where third-party .node binaries load normally.

"mcp_config": {
  "command": "node",
  "args": ["${__dirname}/dist/cli.js"],
  "platform_overrides": {
    "darwin": {
      "command": "/usr/bin/env",
      "args": ["node", "${__dirname}/dist/cli.js"]
    }
  }
}

This works today but relies on an undocumented routing detail and is not something extension authors should have to discover by reverse-engineering app.asar. It also depends on node being resolvable from Claude Desktop's (non-inherited) PATH, which is fragile across environments (nvm, homebrew-arm64, etc.).

Impact

Every MCPB that wants to use a native Node module on macOS is currently broken unless the author stumbles onto the workaround. This includes common dependencies: any SQLite/LevelDB/LMDB backend, image processing (sharp), filesystem watchers (chokidar/fsevents), PTY (node-pty), and many more. The practical outcome is that MCPB authors are forced to either (a) avoid native deps entirely, (b) ship as npx-based MCP servers in claude_desktop_config.json instead of MCPB, or (c) use the undocumented workaround.

Environment

  • Claude Desktop: 1.2581.0 (reproduced); reporters on older 1.2xxx versions also hit it
  • macOS: Darwin 24.6.0 (Sequoia), arm64
  • Example package triggering it: classic-level@^2.0 (same applies to any node-gyp-build prebuild)

Happy to provide additional logs, a minimal reproducer repo, or help verify a fix.

Metadata

Metadata

Assignees

No one assigned

    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