{"slug": "obsidian-vault-on-a-raspberry-pi-obsidian-headless-sync-fastmcp-server-for-oauth", "title": "Obsidian vault on a Raspberry Pi: obsidian-headless sync + FastMCP server for Claude (Tailscale Funnel, GitHub OAuth)", "summary": "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.", "body_md": "Two containers keep a live copy of the Obsidian vault on the Pi and let Claude read and write it:\n\n- **obsidian-sync** — the official`obsidian-headless` client (`ob sync --continuous` ) in a\ncommunity arm64 image. It joins Obsidian Sync as one more device, so the Pi's copy is always\ncurrent, and anything Claude edits on the Pi shows up on the laptop and phone within seconds.\n- **obsidian-mcp** — a ~100-line FastMCP server (`obsidian-mcp/server.py` ) with five tools\n(`list_notes` ,`read_note` ,`search_notes` ,`write_note` ,`append_note` ) over that same folder.\nLogin 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\nreturns no text from them, so a clipped page that reads like an instruction can't ride into a\nchat unseen. Claude Code\non 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,\nconnecting each Claude, revoking access, troubleshooting.\n\nThe vault lives on the **Pi's SD card** at `config/obsidian/vault`, not on the NAS: it's ~50 MB,\ninotify doesn't fire over NFS (so continuous sync would miss edits made on the Pi), and Obsidian\nSync already keeps version history, so `backup.sh` ignores it on purpose. `.obsidian/` (plugin\nstate, workspace layout) is hidden from the MCP tools.\n\n```\nlaptop: Claude Code  ──tailnet──▶  https://mypi….ts.net/mcp  ──▶ obsidian-mcp ──▶ vault ◀── obsidian-sync ◀──▶ Obsidian Sync\nphone:  Claude app   ──▶ Anthropic servers ──public (Funnel)──▶  (same URL)                                       ▲\nphone:  Claude app   ──▶ Anthropic relay  ──▶ `claude remote-control` running ON the Pi in the vault folder ─────┘\n```\n\nThe Claude app never talks to an MCP server itself — Anthropic's servers do, on its behalf. A\ntailnet-only URL is invisible to them, so the phone connector needs **Tailscale Funnel** (a public\nHTTPS URL). The GitHub login + allowlist is what actually guards the notes: the URL alone gets a\nstranger a GitHub consent page and then \"not allowed\". (To take it off the public internet later:\n`sudo tailscale funnel --https=443 off`, then `sudo tailscale serve --bg --https=443 127.0.0.1:8100`\nfor tailnet-only, and use **Remote Control** for the phone.)\n\n1. \n**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\nsync container needs (see Gotchas).\n\n```\nmake notes-token\n```\n\n Put it in `.env` as`OBSIDIAN_AUTH_TOKEN` , with`OBSIDIAN_VAULT_NAME` (the vault's name in\nObsidian → Settings → Sync) and`OBSIDIAN_VAULT_PASSWORD` (its end-to-end password). See`.env.example` .\n2. \n**GitHub OAuth app** — github.com → Settings → Developer settings → OAuth Apps → New:\n  - Homepage URL: `https://mypi.your-tailnet.ts.net`\n  - Authorization callback URL: `https://mypi.your-tailnet.ts.net/auth/callback`\n 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)` .\n3. Homepage URL: \n4. \n**Deploy** from the laptop (`./deploy.sh` ), then watch the first sync pull the vault down:\n\n```\nmake logs svc=obsidian-sync     # first run: sync-setup, then \"watching for changes\"\nls config/obsidian/vault\n```\n\n5. \n**Public HTTPS via Tailscale Funnel** — on the Pi, once. Needs**MagicDNS** and**HTTPS\ncertificates** turned on in the Tailscale admin console (DNS page) first.\n\n```\nmake notes-funnel     # = sudo tailscale funnel --bg --https=443 127.0.0.1:8100\n```\n\n 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\nminutes** before connecting anything: the name only enters public DNS once Funnel is on, and\nresolvers that asked earlier cache \"no such name\" for that long. This is host state, not in\ngit: after a fresh OS install, run it again. Check from anywhere (phone on 4G is a good test):\n\n```\ncurl https://mypi.your-tailnet.ts.net/.well-known/oauth-authorization-server   # JSON = alive\nclaude mcp add --transport http --scope user obsidian https://mypi.your-tailnet.ts.net/mcp\n```\n\n`--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.\n\nConnectors belong to the account, so add it once and every app has it. Mac app or claude.ai →\nSettings → **Connectors** → **Add custom connector** → URL\n`https://mypi.your-tailnet.ts.net/mcp` → Add → **Connect** → GitHub login. Then in any\nchat (Mac, web, or phone): **+** → **Connectors** → switch **obsidian** on, per chat.\nStep-by-step with the phone specifics: [`obsidian-mcp/README.md`](2-obsidian-mcp-README.md#connect-claude).\n\nClaude Code can run *on the Pi* in the vault folder and be driven from the phone or\nclaude.ai/code — the full agent, editing files directly. No MCP, no Funnel, works on Pro/Max.\n\n```\n# one-time, on the Pi\ncurl -fsSL https://claude.ai/install.sh | bash     # native arm64 build\nsudo apt install -y tmux\nclaude                                             # then /login (claude.ai account), then exit\n\n# every time (survives SSH disconnect, not a reboot)\nmake notes-rc                                      # then: tmux attach -t notes-rc, scan the QR\n```\n\nThe session shows up in the Claude app under Code. Edits land in `config/obsidian/vault` as `pi`\nand obsidian-sync pushes them to Obsidian Sync straight away. `--permission-mode default` is set\non purpose: every edit and every command asks for a tap. This session has none of the MCP's\nguards (no writable-folder rule, no hidden-character filter), it can write anywhere in the vault,\nand it reads the vault's `CLAUDE.md` as instructions. The tap is the guard. See\n[Prompt injection](2-obsidian-mcp-README.md#prompt-injection-whats-guarded-and-what-isnt).\n\nClipped web pages and pasted transcripts land in the vault, and a note whose text reads like an\ninstruction can steer a later Claude turn (that's why `MCP_UNTRUSTED_FOLDERS` are read-only and\ntext-less in search). [`./scan-vault.py`](./scan-vault.py) runs **on the Pi** once a day\nand looks in the usual hiding places: invisible Unicode, HTML comments, CSS-hidden text,\nchat-template markers, \"ignore previous instructions\" phrasing, long encoded blobs. No AI in the\nloop, on purpose: a checker that reads the notes with an LLM can be tricked by the very text it\nis looking for.\n\nThe 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.\n\nIt writes `Scratchpad/Injection scan.md` (note, line number and a label; never the text, so the\nreport can't carry a payload into a chat) and Obsidian Sync brings it to the laptop. Expect a\nhandful of harmless hits (clipped articles, templates, notes *about* AI); look at anything you\ndon't recognise.\n\n```\nmake scan                          # run now; exit 1 if anything was flagged\n./scan-vault.py --self-test        # the built-in check\n```\n\nCron, as `pi` (`crontab -e`):\n\n```\n10 4 * * *  /home/pi/pi-nas/scan-vault.py >> /home/pi/pi-nas/scan.log 2>&1   # daily 04:10\n```\n\n- **Two writers, one file.** Claude on the Pi and you on the laptop editing the same note at the\nsame moment →`CONFLICT_STRATEGY=merge` (Obsidian's default) merges line by line rather than\nforking the file. Version history in Obsidian → Sync has every prior state.\n- **Changed the MagicDNS name?** It's in four places:`BASE_URL` in`docker-compose.yml` , the\nGitHub 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).\n- **New `MCP_JWT_SIGNING_KEY`** = every Claude has to log in again. Same if you remove the`<project>_obsidian-mcp-config` Docker volume (it holds the OAuth registrations + tokens; it's a\nnamed volume, not`./config/` , because it must be owned by`pi` and Docker creates missing\nbind-mount dirs as root).\n- **Vault password wrong** → obsidian-sync exits at start with a decryption error; check`make logs svc=obsidian-sync` . It's the vault's own E2E password, not your Obsidian account one.\n- **obsidian-sync dies at \"Logging in\" with a permission error** →`config/obsidian/sync` is\nowned by root. Docker creates missing bind-mount dirs as root, and the image only fixes\nownership when`PUID` differs from its built-in 1000 (ours is 1000). Fix:`sudo chown -R pi:pi config/obsidian && make restart svc=obsidian-sync` .`make notes-token` pre-creates the dirs so a normal first run never hits this.", "url": "https://wpnews.pro/news/obsidian-vault-on-a-raspberry-pi-obsidian-headless-sync-fastmcp-server-for-oauth", "canonical_source": "https://gist.github.com/mrmartineau/475dc3e8ffc6908f1493a05989a116ff", "published_at": "2026-09-09 08:34:40+00:00", "updated_at": "2026-09-18 03:24:35.898832+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "ai-infrastructure"], "entities": ["Obsidian", "Claude", "Anthropic", "Tailscale", "GitHub", "FastMCP", "Raspberry Pi", "Claude Code"], "alternates": {"html": "https://wpnews.pro/news/obsidian-vault-on-a-raspberry-pi-obsidian-headless-sync-fastmcp-server-for-oauth", "markdown": "https://wpnews.pro/news/obsidian-vault-on-a-raspberry-pi-obsidian-headless-sync-fastmcp-server-for-oauth.md", "text": "https://wpnews.pro/news/obsidian-vault-on-a-raspberry-pi-obsidian-headless-sync-fastmcp-server-for-oauth.txt", "jsonld": "https://wpnews.pro/news/obsidian-vault-on-a-raspberry-pi-obsidian-headless-sync-fastmcp-server-for-oauth.jsonld"}}