2026-07-25 Obsidian MCP TCC Break from a Homebrew node Upgrade

Second MCP outage of the day, unrelated to the morning’s 2026-07-25 Remote Obsidian MCP Access Session Rollover. This one was real, and the cause was a brew upgrade I ran hours earlier.

What I set out to do

The connector had been “flaky on restart.” Read the logs on athena and find out what actually happens across a restart.

What I actually did

The logs dated everything precisely. brew upgrade relinked cloudflared at 16:39 (visible as a four-connection tunnel reconnect storm at 16:40) and node 24 to 26.5.0, plus uv, at 17:26. The bridge took a SIGTERM at 17:32:47 and came back at 17:32:58, an 11s hole, re-downloading pydantic-core and installing 31 packages because the uv upgrade had invalidated its cache.

Then Claude Desktop reported The connector's server isn't responding, which turned the post-mortem into a live incident.

Ruled out auth immediately, and the reasoning matters: Cloudflare Access is enforced at the edge, so only requests that pass policy get proxied to the origin. The connector’s requests were appearing in the tunnel log against originService=http://localhost:8788, which means they had already cleared Access. An expired token never reaches athena at all. The morning’s failure mode was genuinely a different one.

The real signal was a ~2 minute lag: every CallToolRequest in the bridge log was followed ~124s later by Incoming request ended abruptly: context canceled in the tunnel log. The bridge accepted tool calls and never answered. No tool call had succeeded since the 17:32 restart; before it, calls were completing at ~6s intervals.

Then I got it wrong twice.

Wrong turn 1: npx. On loopback, tools/list returned in 4.7ms while every tools/call hung past 150s. I bypassed npx and pointed mcp-proxy straight at the mcpvault entrypoint: 61ms. Declared npm exec was stealing stdin, citing lsof showing it holding fd 0 on the same pipe as mcpvault. That lsof detail was ordinary fd inheritance and proved nothing about reads.

Wrong turn 2: the restart. Restarting did not fix it, and the fresh instance hung identically.

What broke the theory was a proper A/B/C: npx @latest, npx pinned, and direct entrypoint, all three from the same shell. All three passed in ~50ms, including the configuration I had declared broken. My earlier test had changed two variables at once, removing npx and moving to an SSH shell, and I credited npx for a difference that belonged to the shell.

The actual split was SSH context vs launchd context, which I had hypothesised early and abandoned too quickly after a weak null (an empty log show for TCC and no user-DB AllFiles rows).

A controlled probe, same script submitted to launchd and run over SSH:

OperationSSHlaunchd
stat vault dirrc=0rc=0
ls vault16 entriesOperation not permitted
node readdirSync16 entrieshangs forever

That is the whole bug. The vault lives under ~/Library/Mobile Documents/, gated by TCC. TCC grants bind to a specific binary, and Homebrew’s node sits at the versioned path /usr/local/Cellar/node/26.5.0/bin/node. The upgrade created a new binary with no grant; the 17:32 restart spawned mcpvault under it. initialize and tools/list never touch the filesystem, so they stayed fast, while every real tool call hung. SSH kept working throughout because sshd carries its own Full Disk Access.

The fix: a stable-path supervisor

Granting FDA to node directly would die at the next brew upgrade, which is the recurring flakiness rather than a fix. Instead, a ~60-line C binary at ~/.local/libexec/obsidian-remote-mcp-supervisor, ad-hoc signed, now the launchd job’s Program. It forks rather than execs, staying alive as the parent so the child tree inherits its TCC responsibility. That is the same model that makes sshd and Terminal work, and it is why exec would have been wrong: exec replaces the process image and TCC re-evaluates against the new binary.

Granted Full Disk Access to that one path in System Settings. Verified through the live launchd bridge: get_vault_stats 52ms, search_notes 151ms, both previously hanging forever.

It also fixed a second, independent bug I found along the way. uv tool uvx forks instead of exec’ing, so launchctl was only ever killing the uv wrapper and the real mcp-proxy orphaned to PID 1, stuck in Waiting for background tasks to complete because its hung tool-call streams never drained. Every restart stranded one. The supervisor forwards SIGTERM to the child’s process group, and the post-fix restart tore the tree down clean.

What was striking

  • Changing two variables at once cost me the most time. The npx theory was well-evidenced and completely wrong, because the control differed from the test in a second way I had not accounted for. The A/B/C that falsified it took two minutes and should have been the first thing I ran.
  • A weak null is not an answer. log show returning nothing for TCC read as “not TCC” when it only meant the predicate matched nothing. I already have this lesson written down and still walked past it.
  • node hangs on a TCC denial where ls gets a clean EPERM. That asymmetry is what disguised a permissions error as a hang, and it is why this presented as “not responding” rather than an error.
  • A 401 is not evidence about someone else’s token. My unauthenticated probe returning 401 says only that Access is enforcing. I nearly re-diagnosed the morning’s rollover on top of a live, unrelated outage.
  • Version-pinned binary paths and TCC are fundamentally incompatible. Any grant to a Homebrew binary is a countdown to the next upgrade.

Top tomorrow

  1. Fix obsidian-remote-mcp.nix:24 and the unpinned uvx mcp-proxy / npx -y ...@latest pair. Not this bug, but a real robustness gap: both resolve from the network on every cold start, and KeepAlive has no ThrottleInterval, so an unreachable registry becomes a 10s hot loop.
  2. Absent-data alert on the bridge, carried over from this morning. It would have caught this in minutes rather than hours.
  3. Consider whether the vault should be served from a non-iCloud clone on athena, which removes TCC from the path entirely at the cost of reintroducing sync latency.