Objective

Move the remote Obsidian MCP off the always-awake MacBook onto an always-on, declaratively-managed cloud host, so the vault MCP is reachable without the Mac being awake, and the whole stack is reproducible from code.

Outcome (2026-06-24): done. The MCP runs on an always-on OCI A1 NixOS box, provisioned by Terraform and configured via nixos-anywhere, with the vault kept in sync by a 30s headless git loop. Verified end-to-end from the Claude desktop app (requests reached the box, 200/202). Merged to main (9 commits).

Superseded (2026-07-03): public serving cut over to athena (the rebuilt 2020 Intel MBP home server), which hosts the MCP against its iCloud vault copy directly — removes the 30s+30s git-polling latency this topology had. The box’s tunnel is stopped and reboot-proofed off (tunnel.env renamed to tunnel.env.disabled; NixOS can’t systemctl disable); gitsync.timer still runs as the GitHub backup path. Box end-state decided 2026-07-03: keep idling as a $0 fallback (Always Free A1, tunnel disabled, gitsync backup running); revisit if it ever stops earning its keep. atlas’s obsidian-git dropped back to 15m the same day — git is backup-only now. See Old MacBook Home Server Setup for the cutover runbook + execution notes.

Context

The connector built on 2026-06-21 (2026-06-21 Remote Obsidian MCP Connector) ran on the Mac (mcpvault + mcp-proxy + cloudflared via launchd), so it only worked while the Mac was awake (held up by a caffeinate agent). Goal was to relocate it to a free/cheap always-on box and manage it the same way as the rest of the dotfiles: single source of truth in code.

What shipped

  • Host: OCI VM.Standard.A1.Flex (1 OCPU / 6 GB aarch64), NixOS 26.11, us-ashburn-1, achhina@129.213.158.200. Landed in one tofu apply the moment the PAYG upgrade completed. Idle usage: ~400 MB RAM, ~0% CPU.
  • Infra as code: Terraform (infra/terraform/, oci provider) owns the VCN + public subnet + instance.
  • Host config: nix/hosts/obsidian-mcp/ (configuration.nix, disko.nix, service.nix, files/gitsync.sh), wired as nixosConfigurations.obsidian-mcp; installed via nixos-anywhere --build-on remote (no aarch64-linux builder registered on the Mac). zramSwap + serial console added for recovery.
  • Services: services.obsidianRemoteMcp = mcp-proxy (loopback /mcp :8788) → npx @bitbonsai/mcpvault, plus cloudflared on the same named tunnel as before, so the Cloudflare Access app + DNS were untouched.
  • Vault sync: headless two-way git (gitsync.sh, writeShellApplication) on a 30s systemd timer, write-scoped GitHub deploy key, never-pause keep-both conflict resolution. The Mac’s obsidian-git is also at 30s (autoSaveInterval/autoPullInterval = 0.5; the plugin multiplies minutes by 60000 with no flooring) → ~30-60s end-to-end both directions.
  • Cutover: the Mac keeps only its loopback bridge (consumed by Claude Code via mcp.nix + Open WebUI via open-webui.nix); programs.obsidianRemoteMcp.runTunnel = false drops its cloudflared agent so only the box serves publicly.
  • Connector favicon (2026-06-25): the claude.ai connector showed no icon because claude.ai resolves favicons via Google’s faviconV2 proxy keyed on the apex achhina.com (not the obsidian-mcp. subdomain), and the apex served nothing. Fixed by shipping a sleek static “under construction” page + the GitHub avatar (favicon.png/.ico) to Cloudflare Pages (achhina-apex) with achhina.com as a custom domain — serves the favicon directly, no redirect. Live + verified; the claude.ai icon itself still lags Google’s cache (hours-to-days).

Next Actions

  • Land the A1 instance (PAYG → one tofu apply)
  • Phase 2: NixOS host config + nixosConfigurations + nixos-anywhere deploy
  • Phase 4: two-way git vault sync (box uses a clean non-iCloud clone; deploy-key auth; keep-both conflicts)
  • Decommission the interim micro (terminated + its VCN deleted)
  • Review hardening (gitsync guard, AD validation, zramSwap, serial console)
  • Tune sync to 30s on both sides
  • Merge feat/oci-obsidian-mcp-declarativemain
  • Apex favicon for the claude.ai connector — static “under construction” page on Cloudflare Pages at achhina.com (2026-06-25)
  • [~] Phase 3: Terraform the Cloudflare Access app/DNS — decided to skip (stable, hand-built, documented; see Decisions)
  • [~] IAM shape-guard — decided to skip (Always-Free-only + budget alert deemed enough)
  • Optional: move the Mac’s .git out of iCloud (gitdir pointer) — the one remaining fragility; the box side is already clean (non-iCloud clone). Kept iCloud because the iPhone Obsidian app needs it.
  • Deep links in tool returns: have MCP tool responses include a portable Obsidian deep link (obsidian://open?vault=achhina&file=...) for every note they return/touch. search_notes already returns a uri field, but in the path form (obsidian:////Users/achhina/...) — Mac-absolute, breaks on iOS/other devices. Switch/add the vault+file form (see 2026-07-13 decision).
  • CLAUDE.md rendering rule: add the deep-link rendering convention below to the vault-root CLAUDE.md (and any agent guidance that references vault notes).

Resources

Notes

  • Auth correction: the plan called for a Cloudflare service token, but it turned out unnecessary — the box reuses the existing named tunnel (cloudflared dials out, no inbound auth), and the desktop connector still does interactive Google OAuth via Access. GitHub sync uses a write-scoped deploy key instead.
  • Secrets are operational files on the box, outside repo/store: /etc/obsidian-mcp/tunnel.env (TUNNEL_TOKEN) and /etc/obsidian-mcp/ssh/{id_ed25519,known_hosts}. Not reproducible from code by design; rebuild = re-place them.
  • Private/, .trash/, Quartz build output are gitignored, so the box does not serve them (more secure; flagged at cutover).
  • E2.1.Micro reported ~498 MB RAM (half advertised) and OOMed dnf; A1 capacity needed PAYG. us-ashburn is the permanent home region.
  • Cost: budget alert obsidian-mcp-spend-guard (~$0.05). OCI budgets are alert-only; real protection = Always-Free resources only.

Decisions

2026-08-09 — Build cryptography from source on x86_64-darwin; serving path still stays on Homebrew

Status: decided and shipped (fix/uv2nix-x86-darwin-cryptography, commit 1e96fbb). Context: the bridge died on 2026-08-01 and stayed dead eight days (2026-08-09 Obsidian MCP Bridge Outage and the Intel Wheel Cliff). Root cause was the unpinned uvx mcp-proxy in athena’s hand-written script, which the 2026-08-03 lockfile pin never reached because the launch command exists in two copies and only the Home Manager one was fixed. That prompted a real attempt to delete the second copy by moving athena onto pkgs.obsidian-mcp-proxy.

Blocker found: the package did not build on Intel. cryptography dropped x86_64 macOS wheels at 49.0.0 (48.0.1 is the last with macosx_10_9_universal2), reached via mcp-proxy → mcp → pyjwt[crypto], so the mcp<2 pin does not avoid it. uv2nix falls back to the sdist, which builds a Rust extension through maturin and vendors no crates. This is the x86_64-darwin sunset arriving through PyPI, not nixpkgs, and earlier than the decision below anticipates.

Decision: supply the crate graph as a rustPlatform.fetchCargoVendor fixed-output derivation plus cryptography’s three declared build backends, scoped with lib.optionalAttrs to x86_64-darwin only. lib/uv-build-system-overrides.nix now takes { pkgs }; both call sites (lib/uv.nix, tests/obsidian-mcp-bridge/default.nix) already had it in scope.

Alternatives considered: constrain the lock below 49.0.0, with disjoint PEP 508 markers so uv forks it and only Intel takes 48.0.1 (tested, works, builds with stock lib/uv.nix and needs no hash at all — rejected because it holds a security-sensitive library back on the one host facing the public hostname, and reopens a lock that exists precisely to stay frozen; a test relock also pulled starlette, uvicorn and sse-starlette along with it). Substitute nixpkgs’ own cryptography, currently 48.0.0 on the 26.05 pin (rejected for the same holdback, plus version skew against the lock).

Rationale: the vendor hash looked like recurring maintenance but is not: just uv-relock is deliberately excluded from the update wrapper, so the hash moves only on an explicit, verified relock. The Rust compile is once per version, not once per switch. Both costs land on athena alone.

Key insight — why the same sdist builds under uvx but not under nix: BUILD ISOLATION. uv isolates builds by default, so it installs the maturin backend and bootstraps an entire Rust toolchain via puccinialin (691 MB cached on athena, rustc 1.97.1) with no system rustc present. uv2nix runs --no-build-isolation, so nothing supplies them. Same sdist, same machine, same architecture; only the flag differs. The consequence for the Homebrew path is ugly: a cryptography bump compiles Rust inside the launchd startup path before :8788 binds, and uv cache prune re-arms it. The cached wheel timestamps show it happening (49.0.0 built Jul 3 23:03, minutes before the agents existed; 50.0.0 built Aug 4 07:54, mid-outage).

The serving path still does NOT move, on new grounds. Migrating would point the launchd agent at a pkgs.writeShellScript store path, which holds no TCC grant and changes on every rebuild, so it can never hold one. The vault is under ~/Library/Mobile Documents/, and the grant lives on the supervisor described in the decision below; the symptom would be the same TCC hang that mimics a network fault. This is an argument the 2026-07-25 decision did not make and that does not expire with nixpkgs’ platform support. The way through, if ever wanted: have Home Manager deploy the bridge script to the supervisor’s fixed exec target ~/.local/bin/obsidian-remote-mcp-bridge and leave launchd pointing at the supervisor, so the grant is preserved and children inherit responsibility.

Verified: aarch64-darwin derivation bit-identical to baseline (no gratuitous rebuild); athena’s HM closure unchanged, hm switch kept generation 14 on the same store path, since the curated server profile builds no uv2nix packages; all three bridge flake checks pass; the package builds on athena and serves 18 vault tools end to end on a spare port. Interim fix still in place: athena’s script carries a hand-applied --from "mcp-proxy==0.12.0" --with "mcp<2" pin (backup .bak alongside). @bitbonsai/mcpvault@latest on the next line remains unpinned and carries the identical risk.

Amended later the same day — the executables DID move; the wiring did not. The paragraph above frames this as import-the-module-or-nothing, which was too binary. Nothing forces launchd’s Program to become a store path, and that was the only part TCC blocks. home-server.nix now installs pkgs.mcpvault and pkgs.obsidian-mcp-proxy as packages only (commit 8cd9c5a), and the hand-written script execs ~/.nix-profile/bin/{mcp-proxy,mcpvault}. Both uvx and npx -y ...@latest are gone from the startup path, so the service resolves nothing at launch and needs no network to boot: the 2026-08-01 defect closed at the root rather than pinned around. mcpvault also bumped 0.12.6 → 0.15.0 (973ea95); upstream ships no git tags, so the input pins the commit whose package.json reads that version.

Why the profile rather than a bare store path: the profile is the stable pointer AND the GC root in one. A store path named only inside a hand-written script is neither. hm switch would not repoint it, and nix-collect-garbage would delete a running service’s dependencies, since nix cannot know that a path-shaped string in a shell script means anything. This also removes any hand-managed nix-store --add-root to forget.

Scope of the 2026-07-25 decision is narrowed, not overturned: the wiring (script, plists, supervisor, cloudflared) stays Homebrew-managed and outside the flake, and launchd still starts the TCC-granted supervisor, which forks so the child tree inherits Full Disk Access. Only the two executables now come from nix. Consequence: athena’s serving path now depends on hm switch as well as brew upgrade. The .bak / .bak2 copies of the previous script sit alongside it for rollback.

Verified live: generation 15; process tree supervisor → store python mcp-proxy → store nodejs mcpvault-0.15.0, with no uv, npx or Homebrew node anywhere; 18 tools; and a real get_vault_stats returning note paths with isError: false, which is the actual TCC proof (a denial hangs rather than errors).

2026-07-25 — Stable-path supervisor binary to hold athena’s TCC grant

Status: decided and shipped. Context: the brew upgrade recorded below (node 24 → 26.5.0 at 17:26) broke the MCP within the hour, not at some future unattended reboot. The vault lives under ~/Library/Mobile Documents/, which is TCC-gated, and TCC grants bind to a specific binary. Homebrew’s node sits at the versioned path /usr/local/Cellar/node/<ver>/bin/node, so the upgrade produced a new binary with no grant. The 17:32 bridge restart spawned mcpvault under it and every vault-touching tool call hung from then on. Full write-up: 2026-07-25 Obsidian MCP TCC Break from a Homebrew node Upgrade.

Decision: the launchd job’s Program is now ~/.local/libexec/obsidian-remote-mcp-supervisor, a small ad-hoc-signed C binary at a path Homebrew never touches. It is granted Full Disk Access once. It forks rather than execs, staying alive as the parent so the child tree (uv → python → npm → node) inherits its TCC responsibility — the same model that makes sshd and Terminal work. Exec would be wrong here: it replaces the process image and TCC re-evaluates against the new binary.

Rationale: granting FDA to node directly is a countdown to the next brew upgrade, which is the recurring flakiness rather than a fix. Version-pinned binary paths and TCC are fundamentally incompatible, so the grant has to live on something stable. This accepts the Homebrew-drift consequence recorded in the decision below instead of fighting it.

Bonus fix: uv tool uvx forks instead of exec’ing, so launchctl was only killing the uv wrapper while the real mcp-proxy orphaned to PID 1, stuck in Waiting for background tasks to complete. Every restart stranded one. The supervisor forwards SIGTERM to the child’s process group and tears the tree down cleanly.

Caveat: the grant binds to the binary’s ad-hoc signature, so recompiling the supervisor invalidates it and needs a re-grant via GUI. Documented in the source header. The binary only ever execs a fixed path, so it should never need rebuilding.

Alternatives considered: grant FDA to the current node binary (rejected — dies at the next upgrade); serve the vault from a non-iCloud git clone on athena (viable, removes TCC from the path entirely, but reintroduces the sync latency the 2026-07-03 cutover existed to eliminate — kept as the fallback if the responsibility chain ever stops inheriting).

Verified: through the live launchd bridge, get_vault_stats 52ms and search_notes 151ms, both previously hanging indefinitely.

2026-07-25 — athena’s MCP stays Homebrew-managed, outside the Nix flake (ratifying 2026-07-03)

Status: decided (records an existing decision that was only captured in the Old MacBook Home Server Setup runbook, where it kept being re-discovered as if it were drift). Context: athena’s bridge and cloudflared connector are plain launchd plists with hand-written wrappers in ~/.local/bin calling Homebrew binaries, not store symlinks. Against this repo’s “Total Declarative Control” principle that reads like decay, and I mischaracterised it as such before checking the history.

Decision: the serving path stays on Homebrew. home-server.nix continues to omit coding-agents/common/obsidian-remote-mcp.nix from its curated import list, and athena’s MCP is updated with brew upgrade, never hm switch.

Rationale: nixpkgs 26.05 is the last release supporting x86_64-darwin (binaries until end of 2026; 26.11 drops the platform), while Homebrew supports Intel Sequoia for the machine’s useful life to ~2027. Pinning a 24/7 service to a platform nixpkgs is actively dropping buys nothing on hardware that outlives the support window. Sequencing also forced the original call: the MCP went up 2026-07-03 23:17, and athena’s HM profile did not exist until 2aff736 at 11:25 the next morning, so there was no homeConfigurations."x86_64-darwin" to switch.

Alternatives considered: import the module on athena (rejected — drags node/uv/cloudflared onto the 26.05 pin and would register a second connector on the same named tunnel, round-robining requests across divergent vault copies); wait and migrate once HM landed (rejected — the platform-sunset reasoning does not expire, and HM arriving for shell/CLI does not change it).

Scope note: the 2026-07-03 runbook wording “Homebrew-only, not the Nix dotfiles flake” was partially superseded the next day. Home Manager does run on athena now for shell, git, neovim, tmux and packages. Only the MCP serving path stayed on Homebrew. Consequence to remember: cloudflared, node and uv on athena drift on Homebrew’s cadence, invisible to hm switch. A brew upgrade there bumped node 24 → 26.5.0 under a 21-day-old bridge process, which would first have been exercised at an unattended reboot.

Follow-through: 06baa81 corrected home.nix and the runTunnel description, both of which still named the retired NixOS box as tunnel owner, and made the omission in home-server.nix explicit. That fix was logged in the 2026-07-03 journal as “Top 3 tomorrow #1” and had sat undone since.

2026-07-25 — Stay on 1-month interactive Access; decline the service token

Status: decided. Context: the monthly Access session rolled over and presented as an outage (see 2026-07-25 Remote Obsidian MCP Access Session Rollover). Prompted a look at whether the re-auth cadence could be lengthened or removed. Verified in the dashboard that all three timers are still at max with no drift: global = 1 month, app = 1 month, policy “Allow owner” = “Same as application session duration” (defers).

Decision: keep interactive Access at 1 month. Do not adopt a Cloudflare service token.

Key finding — the options are not a continuum. The app Session Duration dropdown tops out at 1 month (No duration / 15 min / 30 min / 6 h / 12 h / 24 h / 1 week / 2 weeks / 1 month), while service tokens start at 1 year (then 2 / 5 / 10 / non-expiring). Nothing exists between one month and one year, so there was no modest improvement to buy.

Alternatives considered: (a) raise the app session duration — impossible, 1 month is the ceiling and the global timer would cap it regardless; (b) service token via the standard CF-Access-Client-Id/Secret pair — blocked, since claude.ai connector request headers are restricted to an allowlist (authorization, x-api-key, x-auth-token); (c) service token via Cloudflare’s single-header mode, read_service_tokens_from_header: "Authorization" with a JSON value — technically viable and authorization is allowlisted, but gated on Claude’s request-header beta and would require re-adding the connector as header-auth-only since OAuth owns the Authorization header.

Rationale: only the claude.ai connector is Access-gated at all. Claude Code (mcp.nix:38-40) and Open WebUI both reach the bridge at 127.0.0.1:8788 directly, so a service token would harden the clients that already bypass Access while doing nothing for the one that re-auths. It also trades a near-silent monthly tap for a long-lived shared secret living in a cloud connector config, plus an API-only setting invisible in the dashboard — precisely the silent-drift pattern that already bit us with athena’s hand-rolled setup. The monthly cadence was never the defect; its silence was, so the fix is an absent-data alert on the bridge. Revisit only if a genuinely headless client starts hitting the public hostname.

Status: decided (implementation pending — see Next Actions). Context: when an agent (Claude via this MCP) reads or edits a vault note, the user wants a tappable link back to the note. Raw obsidian:// URIs are not auto-linkified by most chat UIs (they only linkify http/https), but markdown-wrapped URIs are tappable in the Claude apps, and iOS/macOS route the scheme to Obsidian natively — verified working on iOS 2026-07-13.

Decision — link format (portable, device-independent):

obsidian://open?vault=achhina&file=<vault-relative path, no .md, URL-encoded>

  • Encode at minimum: space → %20, &%26 (unencoded & breaks the query string), /%2F (safe habit).
  • Do not use the path form (obsidian://open?path=... or obsidian:////Users/...): it embeds the Mac’s absolute iCloud path and fails on iOS and any other device. The uri field search_notes currently returns is this broken form.

Decision — rendering convention for agents (goes in vault-root CLAUDE.md / agent guidance):

When you reference, read, create, or modify a vault note in a response, end the response with tappable deep link(s) to the note(s), one per line, as markdown links: [Note Name](obsidian://open?vault=achhina&file=Atlas%2FNotes%2FNote%20Name) Never emit a bare obsidian:// URI (dead text in most UIs) and never use path-form URIs (device-specific). Multiple notes → link each once; link the most relevant note first.

Rationale: vault+file resolves on every device with the vault; markdown wrapping is the only reliably tappable presentation in chat UIs; end-of-response placement makes the link predictable without cluttering prose. Alternative considered: HTTPS redirect (obsid.net or a self-hosted Cloudflare Worker) for apps that refuse custom schemes entirely — unnecessary for the Claude apps since markdown links work; revisit only if links need to survive WhatsApp/Slack.

2026-06-25 — Static Pages artifact (not a Worker) for the connector favicon

Status: decided. Context: the claude.ai connector showed no icon for obsidian-mcp.achhina.com. claude.ai resolves favicons via Google’s faviconV2 proxy keyed on the registrable domain (achhina.com), and the apex served nothing → 404. Decision: ship a tiny static “under construction” page + the GitHub avatar as favicon.png/.ico to Cloudflare Pages (project achhina-apex) with achhina.com as a custom domain (apex CNAME flattening), serving the bytes directly (no redirect). Alternatives: a Cloudflare Worker proxying the avatar (overkill — runs code per request for a static image; though it would auto-track a live avatar); a Cloudflare redirect rule (rejected — some favicon crawlers don’t follow 3xx). Rationale: an artifact is the right tool for a static favicon, and Pages was already in use (obsidian-notes). Caveats: the Pages dashboard uploader uses the browser File System Access API, which Playwright can’t drive, so deploys are wrangler pages deploy or a manual zip upload; and the claude.ai icon still lags Google’s faviconV2 cache (hours-to-days), which can’t be forced.

2026-06-24 — Keep git (not Syncthing/rclone/iCloud) for the box; sync at 30s

Status: decided. Context: wanted near-instant sync. Decision: keep git (box deploy key + 30s timer) and set the Mac’s obsidian-git to 30s; skip Syncthing, rclone box↔Mac, and iCloud-on-Linux. Rationale: the box is headless, so git wins (scoped auth, real 3-way merge, versioned off-site backup); Syncthing can’t reach the iOS Obsidian app and is fragile on an iCloud folder; rclone is polling-only and loses backup/merge; iCloud-on-Linux needs the whole Apple ID on the box + Advanced Data Protection off. ~30-60s is the practical floor for this iOS-app + headless-server topology — the Mac is an unavoidable bridge between the iCloud and git worlds.

2026-06-24 — Reference the tunnel, don’t Terraform Cloudflare

Status: decided. Context: the Access app/policy/DNS/tunnel are live and serving; a token-based tunnel’s secret isn’t recoverable on import (Terraform would recreate it and break the box’s token). Decision: skip Phase 3 — keep the box on the existing tunnel and leave the Access/DNS control-plane hand-built. Alternatives: import everything (risky to the live token) or fully manage a TF-generated tunnel (disruptive). Rationale: nothing there changes on its own; it’s documented in reference-obsidian-mcp-cloudflare-access / Cloudflare Access, so Terraforming it is optional polish with real downside.

2026-06-24 — Never-pause keep-both conflict resolution

Status: decided. Context: box and Mac both push to the shared remote. Decision: on a merge conflict the box keeps BOTH versions (remote at the canonical path, the box’s as a timestamped .conflict- sidecar) and pushes rather than pausing; truly-unresolvable cases (e.g. rename/rename) abort safely and retry next tick. Rationale: never lose an edit, never silently commit conflict markers, never block sync.

2026-06-23 — Upgrade to PAYG to get the A1

Status: decided. Context: free-tier A1 chronically out of capacity in Ashburn (~40 failed attempts; months-long shortage). Decision: upgrade to PAYG (one-way door), which keeps the A1 at $0 (Always Free) but removes capacity deprioritization. Alternatives: keep retrying the free lottery (likely never), or settle for the 498 MB micro (too small, x86 build pain). Rationale: only realistic path to the 6 GB aarch64 box — and it worked instantly once PAYG propagated.

2026-06-23 — Terraform owns a dedicated VCN

Status: decided. Context: hit the 2-VCN free-tier limit. Decision: deleted the unused April VCN to free a slot so Terraform creates and owns its own VCN/subnet. Rationale: keeps the network declarative; end state is one TF-owned VCN now that the micro is retired.