2026-08-03 Making the Obsidian MCP Bridge Pure and Rotating Launchd Logs
What I set out to do
Find out why the Obsidian MCP server wasn’t connected. Expected the usual suspect — the monthly Cloudflare Access session rollover.
What I actually did
It wasn’t Access at all. The bridge had been dead since Aug 2 15:45, crash-looping under KeepAlive. mcp-proxy imports request_ctx from mcp.server.lowlevel.server at module scope, mcp 2.0.0 removed it, and the process died before binding its port. The reason a machine that had rebuilt nothing could break: the wrapper ran uvx mcp-proxy, resolving dependencies at launchd start time, and mcp-proxy declares mcp>=1.17.0 with no upper bound.
First fix was --with 'mcp<2' — the symptom. That left the real defect in place, and I compounded it: I moved the Python half into a uv2nix lockfile but left the Node half as npx -y @bitbonsai/mcpvault@latest, still fetching from npm every launch. Then, because a build sandbox has no network, I declared the read path “untestable in a sandbox” and built a bespoke tests/live/ directory with its own runner wired into the update script. Elaborate scaffolding to route around an impurity instead of removing it. The push back was the right one: why is this a live test, and what’s the nix way? Packaging mcpvault from its repo (the published tarball ships no lockfile) closed it — both halves in the store, nothing resolved at launch, and the end-to-end read collapsed into an ordinary flake check with a fixture vault on an ephemeral port. tests/live/ and the runner block were deleted the same day they were written.
Also got importNpmLock wrong in a way worth remembering. It failed ENOTCACHED, I checked whether it had dropped zod-to-json-schema, found the package present, and concluded its cache was faulty — writing that into a commit message. Wrong twice over. importNpmLock {} and importNpmLock.buildNodeModules {} are different functions: the first returns a lockfile patched to store paths, the second builds a dev-shell node_modules and leaves the lock alone. I passed the second to a hook expecting the first, so all 288 entries still pointed at the registry and the offline install reached for the network. The idiomatic call works and needs no hash at all.
Then the log question. The crash loop had written 5.4MB — 5,336 tracebacks, 77% of the file. Turns out launchd has no rotation, and neither home-manager nor nix-darwin wraps StandardOutPath/StandardErrorPath; both expose them as bare plist mirrors, and a code search across nix-darwin for rotation tools returns nothing. On Linux the same services log to journald and the problem doesn’t exist, which is why nobody upstream solved it. Our own ntfy.nix is the proof: same service, launchd gets two unbounded files, systemd gets ExecStart and Restart and nothing else.
I twice told him rotation was unworkable — newsyslog needs root in /etc, and launchd holds the descriptor from spawn so rotation breaks the service. Both were half-true. newsyslog -f takes an alternate config, and the stale-descriptor problem only affects rename-based rotation. logrotate’s copytruncate empties the file in place. Verified rather than trusted: forced a rotation of the live bridge’s log, watched it go to 0, sent a request, watched the same PID write to it again with no restart. Landed as a user launchd agent running logrotate hourly against a store-path config — no root, nothing in /etc. First pass took the 5.4MB down to a 53KB archive.
Last catch was mine to own: I had each module register its log paths in local.logRotate.paths, which restates what StandardErrorPath already declares. Two copies, free to drift, and drift fails silently. Reading the paths off config.launchd.agents instead doubled coverage on the spot — aerospace, glance and borders were all writing unbounded logs my hand-written list missed.
What was striking
Three times I built something to work around a constraint I hadn’t actually established. The live-test directory existed because I believed the read couldn’t be hermetic; it could, once the service stopped fetching from the internet to start. The npmDepsHash existed because I believed importNpmLock was broken; I’d called the wrong function. The “no rotation possible” conclusion survived two messages on a /etc-needs-root claim that a single man page contradicted. Every one of them was a workaround built on an unverified premise, and in each case the premise was the thing that needed testing.
The other half is that every correction came from being asked a plain question rather than told an answer. “Why is this a live test?” and “isn’t that undeclared deps?” each took out a whole structure I’d have kept building on.
Related
- Commits
563f76dthroughbb1c4c3in the dotfiles repo - 2026-08-03 Ask Rule Precedence and the gh api Read Prompts
- 2026-08-01 Grep Translation Hook Removed After Finding Upstream Shims