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-headlessclient (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 inMCP_ALLOWED_GITHUB_LOGINgets 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.mdhas 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.)
-
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}aspi, which the sync container needs (see Gotchas).make notes-tokenPut it in
.envasOBSIDIAN_AUTH_TOKEN, withOBSIDIAN_VAULT_NAME(the vault's name in Obsidian → Settings → Sync) andOBSIDIAN_VAULT_PASSWORD(its end-to-end password). See.env.example. -
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), plusMCP_JWT_SIGNING_KEY=$(openssl rand -hex 32). - Homepage URL:
-
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 -
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:8100The 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.
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.
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 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_URLindocker-compose.yml, the GitHub OAuth app's callback URL, every connected Claude (re-add the connector), and theobsidian-mcphealth link inpi-dashboard/public/index.html(WEBUImap). - New
MCP_JWT_SIGNING_KEY= every Claude has to log in again. Same if you remove the<project>_obsidian-mcp-configDocker volume (it holds the OAuth registrations + tokens; it's a named volume, not./config/, because it must be owned bypiand Docker creates missing bind-mount dirs as root). - 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. - obsidian-sync dies at "Logging in" with a permission error →
config/obsidian/syncis owned by root. Docker creates missing bind-mount dirs as root, and the image only fixes ownership whenPUIDdiffers from its built-in 1000 (ours is 1000). Fix:sudo chown -R pi:pi config/obsidian && make restart svc=obsidian-sync.make notes-tokenpre-creates the dirs so a normal first run never hits this.