Obsidian vault on a Raspberry Pi: obsidian-headless sync + FastMCP server for Claude (Tailscale Funnel, GitHub OAuth) A developer built a two-container setup on a Raspberry Pi that keeps a live Obsidian vault synced via the official obsidian-headless client while exposing it to Claude through a roughly 100-line FastMCP server with five tools (list_notes, read_note, search_notes, write_note, append_note). Access is gated by GitHub OAuth with a single-user allowlist, untrusted folders are read-only to the model, and the phone's Claude app reaches the server over a public HTTPS URL via Tailscale Funnel. Two containers keep a live copy of the Obsidian vault on the Pi and let Claude read and write it: - obsidian-sync — the official obsidian-headless client ob sync --continuous in a community arm64 image. It joins Obsidian Sync as one more device, so the Pi's copy is always current, and anything Claude edits on the Pi shows up on the laptop and phone within seconds. - obsidian-mcp — a ~100-line FastMCP server obsidian-mcp/server.py with five tools list notes , read note , search notes , write note , append note over that same folder. Login is GitHub OAuth, and only the GitHub user in MCP ALLOWED GITHUB LOGIN gets through. MCP UNTRUSTED FOLDERS web clippings, third-party text are read-only for Claude and search returns no text from them, so a clipped page that reads like an instruction can't ride into a chat unseen. Claude Code on the laptop and the Claude app on the phone both reach it over HTTPS via Tailscale. obsidian-mcp/README.md 2-obsidian-mcp-README.md has the full story: tools, login, connecting each Claude, revoking access, troubleshooting. The vault lives on the Pi's SD card at config/obsidian/vault , not on the NAS: it's ~50 MB, inotify doesn't fire over NFS so continuous sync would miss edits made on the Pi , and Obsidian Sync already keeps version history, so backup.sh ignores it on purpose. .obsidian/ plugin state, workspace layout is hidden from the MCP tools. laptop: Claude Code ──tailnet──▶ https://mypi….ts.net/mcp ──▶ obsidian-mcp ──▶ vault ◀── obsidian-sync ◀──▶ Obsidian Sync phone: Claude app ──▶ Anthropic servers ──public Funnel ──▶ same URL ▲ phone: Claude app ──▶ Anthropic relay ──▶ claude remote-control running ON the Pi in the vault folder ─────┘ The Claude app never talks to an MCP server itself — Anthropic's servers do, on its behalf. A tailnet-only URL is invisible to them, so the phone connector needs Tailscale Funnel a public HTTPS URL . The GitHub login + allowlist is what actually guards the notes: the URL alone gets a stranger a GitHub consent page and then "not allowed". To take it off the public internet later: sudo tailscale funnel --https=443 off , then sudo tailscale serve --bg --https=443 127.0.0.1:8100 for tailnet-only, and use Remote Control for the phone. 1. Obsidian Sync token — on the Pi, interactive asks email, password, 2FA code . Run this before the first deploy: it also creates config/obsidian/{vault,sync} as pi , which the sync container needs see Gotchas . make notes-token Put it in .env as OBSIDIAN AUTH TOKEN , with OBSIDIAN VAULT NAME the vault's name in Obsidian → Settings → Sync and OBSIDIAN VAULT PASSWORD its end-to-end password . See .env.example . 2. GitHub OAuth app — github.com → Settings → Developer settings → OAuth Apps → New: - Homepage URL: https://mypi.your-tailnet.ts.net - Authorization callback URL: https://mypi.your-tailnet.ts.net/auth/callback Put the Client ID + a generated client secret in .env MCP GITHUB CLIENT ID , MCP GITHUB CLIENT SECRET , plus MCP JWT SIGNING KEY=$ openssl rand -hex 32 . 3. Homepage URL: 4. Deploy from the laptop ./deploy.sh , then watch the first sync pull the vault down: make logs svc=obsidian-sync first run: sync-setup, then "watching for changes" ls config/obsidian/vault 5. Public HTTPS via Tailscale Funnel — on the Pi, once. Needs MagicDNS and HTTPS certificates turned on in the Tailscale admin console DNS page first. make notes-funnel = sudo tailscale funnel --bg --https=443 127.0.0.1:8100 The first time, it prints a login.tailscale.com/f/funnel?node=... link and then waits it looks stuck . Open the link, click Allow, and it finishes by itself. Then wait five minutes before connecting anything: the name only enters public DNS once Funnel is on, and resolvers that asked earlier cache "no such name" for that long. This is host state, not in git: after a fresh OS install, run it again. Check from anywhere phone on 4G is a good test : curl https://mypi.your-tailnet.ts.net/.well-known/oauth-authorization-server JSON = alive claude mcp add --transport http --scope user obsidian https://mypi.your-tailnet.ts.net/mcp --scope user = available in every project, not just the folder you ran it in. Then inside Claude Code: /mcp → obsidian → Authenticate → GitHub login in the browser. Connectors belong to the account, so add it once and every app has it. Mac app or claude.ai → Settings → Connectors → Add custom connector → URL https://mypi.your-tailnet.ts.net/mcp → Add → Connect → GitHub login. Then in any chat Mac, web, or phone : + → Connectors → switch obsidian on, per chat. Step-by-step with the phone specifics: obsidian-mcp/README.md 2-obsidian-mcp-README.md connect-claude . Claude Code can run on the Pi in the vault folder and be driven from the phone or claude.ai/code — the full agent, editing files directly. No MCP, no Funnel, works on Pro/Max. one-time, on the Pi curl -fsSL https://claude.ai/install.sh | bash native arm64 build sudo apt install -y tmux claude then /login claude.ai account , then exit every time survives SSH disconnect, not a reboot make notes-rc then: tmux attach -t notes-rc, scan the QR The session shows up in the Claude app under Code. Edits land in config/obsidian/vault as pi and obsidian-sync pushes them to Obsidian Sync straight away. --permission-mode default is set on purpose: every edit and every command asks for a tap. This session has none of the MCP's guards no writable-folder rule, no hidden-character filter , it can write anywhere in the vault, and it reads the vault's CLAUDE.md as instructions. The tap is the guard. See Prompt injection 2-obsidian-mcp-README.md prompt-injection-whats-guarded-and-what-isnt . Clipped web pages and pasted transcripts land in the vault, and a note whose text reads like an instruction can steer a later Claude turn that's why MCP UNTRUSTED FOLDERS are read-only and text-less in search . ./scan-vault.py ./scan-vault.py runs on the Pi once a day and looks in the usual hiding places: invisible Unicode, HTML comments, CSS-hidden text, chat-template markers, "ignore previous instructions" phrasing, long encoded blobs. No AI in the loop, on purpose: a checker that reads the notes with an LLM can be tricked by the very text it is looking for. The MCP handles the hidden-text trick itself at both edges: invisible Unicode is stripped from what Claude reads and refused in what it writes, and chat-template markers are refused on write. The scan is for what those can't catch: visible text that reads like an order. It writes Scratchpad/Injection scan.md note, line number and a label; never the text, so the report can't carry a payload into a chat and Obsidian Sync brings it to the laptop. Expect a handful of harmless hits clipped articles, templates, notes about AI ; look at anything you don't recognise. make scan run now; exit 1 if anything was flagged ./scan-vault.py --self-test the built-in check Cron, as pi crontab -e : 10 4 /home/pi/pi-nas/scan-vault.py /home/pi/pi-nas/scan.log 2 &1 daily 04:10 - Two writers, one file. Claude on the Pi and you on the laptop editing the same note at the same moment → CONFLICT STRATEGY=merge Obsidian's default merges line by line rather than forking the file. Version history in Obsidian → Sync has every prior state. - Changed the MagicDNS name? It's in four places: BASE URL in docker-compose.yml , the GitHub OAuth app's callback URL, every connected Claude re-add the connector , and the obsidian-mcp health link in pi-dashboard/public/index.html WEBUI map . - New MCP JWT SIGNING KEY = every Claude has to log in again. Same if you remove the