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-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.