Claude, meet my Obsidian vault A developer detailed how they gave the Claude app on their phone read and write access to their Obsidian notes using a Raspberry Pi, Obsidian Sync, a 100-line MCP server, and Tailscale Funnel. The setup uses the official obsidian-headless client in Docker to keep a live vault copy and a FastMCP server with four tools, secured by GitHub OAuth, enabling Claude to edit notes from any device. Claude, meet my Obsidian vault How I gave the Claude app on my phone read and write access to my Obsidian notes, using a Raspberry Pi, Obsidian Sync, a 100-line MCP server and Tailscale Funnel. My notes live in Obsidian https://obsidian.md . For a while now I’ve been letting Claude and Codex loose on them: job application notes, meeting notes, the odd bit of research. That worked, but only through Claude Code in a terminal, on the laptop, with the vault folder open. Powerful, but not exactly “on the go”. What I actually wanted was to open the Claude app on my phone on the train and say “add these interview notes to the Acme file”, and have it just happen. That now works. The pieces are a Raspberry Pi that already runs my home NAS, the official Obsidian Sync headless client, a tiny MCP https://modelcontextprotocol.io server, and Tailscale Funnel to put a login page on the internet. Everything is in a gist https://gist.github.com/mrmartineau/475dc3e8ffc6908f1493a05989a116ff : the server, the Dockerfile, the compose services, an .env example and the full setup notes. This post is the how and the why. TL;DR - obsidian-sync : the official obsidian-headless client in a Docker container joins Obsidian Sync as one more device, so the Pi always has a live copy of the vault - obsidian-mcp : a ~100-line Python server on FastMCP https://gofastmcp.com with four tools: list notes , read note , write note , search notes - Login is GitHub OAuth , and exactly one GitHub account is allowed past it - Tailscale Funnel gives it a public HTTPS URL, which the Claude phone app needs because Anthropic’s servers do the connecting, not the phone - Add it once as a custom connector in Claude and it’s on every surface: Mac app, claude.ai, phone, Claude Code Why not just point Claude Code at the vault? I did, and I still do. But Claude Code needs a laptop, a terminal and the vault folder. The Claude app on my phone can’t see a folder on my Mac. What it can see is a remote MCP server with a URL, as long as that URL is reachable from Anthropic’s side and speaks OAuth. So the job became: get a copy of the vault somewhere that’s always on, put an MCP server in front of it, and make the login boring and safe. The Pi was already on, already running Docker, already on my Tailscale https://tailscale.com network. Done deal. The vault copy: obsidian-sync Obsidian ships a headless CLI, ob , and someone has kindly wrapped it in an arm64 Docker image belphemur/obsidian-headless-sync-docker https://github.com/Belphemur/obsidian-headless-sync-docker . Run ob sync --continuous in it and the Pi becomes another device on your Obsidian Sync account. Edits made on the Pi flow back to the laptop and phone; edits made anywhere else land on the Pi within seconds. obsidian-sync: image: ghcr.io/belphemur/obsidian-headless-sync-docker:latest container name: obsidian-sync environment: OBSIDIAN AUTH TOKEN: ${OBSIDIAN AUTH TOKEN} VAULT NAME: ${OBSIDIAN VAULT NAME} VAULT PASSWORD: ${OBSIDIAN VAULT PASSWORD:-} E2E password; empty if the vault isn't encrypted DEVICE NAME: zm-pi how the Pi shows up in Sync's version history CONFLICT STRATEGY: merge two writers Claude here, you elsewhere : merge, don't fork volumes: - ./config/obsidian/vault:/vault - ./config/obsidian/sync:/home/obsidian/.config restart: unless-stopped Two things I got wrong first time round, so you don’t have to: - Keep the vault on the SD card, not on the NAS. Mine is about 50 MB. inotify doesn’t fire over NFS, so continuous sync silently misses edits made on the Pi. And Obsidian Sync already keeps version history, so there’s nothing here that needs backing up anyway. - Create the bind-mount folders as your user before the first docker compose up . Docker creates a missing bind directory as root, and then ob login can’t write its config. I have a make notes-token target that does the mkdir and then runs the interactive ob login to get the auth token. CONFLICT STRATEGY: merge matters too. Claude is going to write to a note on the Pi at roughly the same time I might be editing it on the phone. Merge is Obsidian’s default and it merges line by line rather than forking the file into a conflict copy. The MCP server: four tools and a trust boundary This is the whole thing, minus the docstrings. It’s FastMCP https://gofastmcp.com over Streamable HTTP. python from fastmcp import FastMCP from fastmcp.exceptions import ToolError from fastmcp.server.auth.providers.github import GitHubProvider from fastmcp.server.dependencies import get access token VAULT = Path os.environ.get "VAULT PATH", "/vault" .resolve ALLOWED LOGIN = os.environ "ALLOWED GITHUB LOGIN" mcp = FastMCP "obsidian", auth=GitHubProvider client id=os.environ "GITHUB CLIENT ID" , client secret=os.environ "GITHUB CLIENT SECRET" , base url=os.environ "BASE URL" , jwt signing key=os.environ "JWT SIGNING KEY" , fixed key - tokens survive restarts require authorization consent="remember", , def path rel: str = "" - Path: """Trust boundary. Who is asking, and is the path really inside the vault?""" token = get access token login = token.claims.get "login" if token else None if login = ALLOWED LOGIN: raise ToolError f"GitHub user {login r} is not allowed to touch this vault" p = VAULT / rel .resolve resolve follows symlinks, so a link pointing out is caught too if p = VAULT and VAULT not in p.parents: raise ToolError f"path escapes the vault: {rel}" if any part.startswith "." for part in p.relative to VAULT .parts : raise ToolError f"dot-folders/files are off limits: {rel}" return p @mcp.tool def read note path: str - str: """Read one note. path is vault-relative, e.g. "Work/Jobs/Acme.md".""" p = path path if not p.is file : raise ToolError f"no such note: {path}" return p.read text encoding="utf-8" @mcp.tool def write note path: str, content: str - str: """Create or overwrite a note with content . Missing folders are created.""" p = path path p.parent.mkdir parents=True, exist ok=True p.write text content, encoding="utf-8" return p.relative to VAULT .as posix list notes and search notes are the same shape: call path first, then walk .md files. Search is a full scan of every note on every call. With about 500 notes it’s instant, so it stays that way until it isn’t. Every tool starts with path , and path does three things: 1. Who is asking? FastMCP has already done the GitHub OAuth dance and put the GitHub login in the token claims. If it isn’t the one account in ALLOWED GITHUB LOGIN , the tool refuses. Anyone else who finds the URL can log in to GitHub, click Authorize, and then get “not allowed” on every single call. 2. Is the path inside the vault? resolve follows symlinks, so a link that points outside is caught as well. 3. Is it hiding in a dot-folder? .obsidian/ holds plugin state and workspace layout, not notes. Claude has no business in there, so it’s invisible and unwritable. I deliberately didn’t build an “append” or “patch” tool. write note replaces the whole file, and in practice Claude reads first and writes the merged result. Obsidian Sync’s version history has every previous state if it ever gets that wrong. The Dockerfile is python:3.12-slim plus pip install fastmcp . The one non-obvious bit: FastMCP keeps OAuth client registrations and tokens under $FASTMCP HOME , and that has to be a named Docker volume owned by the same uid the container runs as, or every Claude logs in again after each rebuild. obsidian-mcp: build: ./obsidian-mcp container name: obsidian-mcp user: "1000:1000" write notes as pi , same owner obsidian-sync uses environment: BASE URL: https://your-pi.your-tailnet.ts.net GITHUB CLIENT ID: ${MCP GITHUB CLIENT ID} GITHUB CLIENT SECRET: ${MCP GITHUB CLIENT SECRET} JWT SIGNING KEY: ${MCP JWT SIGNING KEY} openssl rand -hex 32 ALLOWED GITHUB LOGIN: ${MCP ALLOWED GITHUB LOGIN} volumes: - ./config/obsidian/vault:/vault - obsidian-mcp-config:/config OAuth registrations + tokens FASTMCP HOME ports: - 127.0.0.1:8100:8000 restart: unless-stopped Note the port binding: 127.0.0.1 only. Nothing on the LAN talks to this container directly. Getting it on the internet: Tailscale Funnel Here’s the bit that surprised me. Claude Code on the laptop only needs the server on my tailnet, and tailscale serve covers that. But the Claude phone app never talks to the Pi. Anthropic’s servers do, on my behalf, and they aren’t on my tailnet. So the MCP server needs a real public HTTPS URL. Tailscale Funnel https://tailscale.com/kb/1223/funnel is the least-effort way I know to do that. It terminates HTTPS with a real certificate at https://your-pi.your-tailnet.ts.net and forwards to the loopback port. sudo tailscale funnel --bg --https=443 127.0.0.1:8100 You need MagicDNS and HTTPS certificates enabled in the Tailscale admin console first. The first run prints a link to enable Funnel on your tailnet and then waits , looking stuck. Open the link, click Allow, and it carries on by itself. Check from a phone on 4G, not Wi-Fi: curl https://your-pi.your-tailnet.ts.net/.well-known/oauth-authorization-server JSON back means the world can reach the login page. That is all the world can reach. Yes, my notes server has a public URL. What’s behind it is an OAuth 2.1 login page, a GitHub consent screen, and a server that refuses every tool call from any account but mine. If I ever want it gone, tailscale funnel off takes it off the internet, changing JWT SIGNING KEY kicks every client out, and revoking the OAuth app on GitHub kills it dead. The GitHub OAuth app One OAuth app on GitHub Settings → Developer settings → OAuth Apps → New : - Homepage URL: https://your-pi.your-tailnet.ts.net - Authorization callback URL: https://your-pi.your-tailnet.ts.net/auth/callback Client ID and secret go in .env . FastMCP’s GitHubProvider is an OAuth proxy : it speaks the OAuth 2.1 plus Dynamic Client Registration flow that Claude expects on the front, and plain GitHub OAuth on the back. Claude never sees the GitHub app’s credentials. That’s why, when the connector dialog in Claude asks about an OAuth client, the answer is never “use your own”. Connecting Claude Connectors belong to your Claude account, not to a device. Add it once and it’s everywhere. Mac app or claude.ai : Settings → Connectors → Add custom connector. Name it, paste https://your-pi.your-tailnet.ts.net/mcp , set authentication to Always required , and for the OAuth client pick Use Anthropic’s hosted client metadata or register one automatically if that fails . Click Connect, log in to GitHub, click Allow on the consent page. Then in a chat, + → Connectors → switch it on. It’s per chat. Phone : nothing to install. Open a new chat, tap + , Connectors, switch it on. It’s the same account, so the Mac connection already covers the login. Claude Code : claude mcp add --transport http --scope user obsidian https://your-pi.your-tailnet.ts.net/mcp claude mcp login obsidian And because it’s plain Streamable HTTP with standard OAuth, Cursor, VS Code and the MCP Inspector all take the same URL. What it’s like to use I ask in plain words and Claude picks the tools. “What did I write about the Deco mesh?” is a search notes then a read note . “Add today’s interview notes to Work/Jobs/Acme.md” is a read note then a write note . The edit lands on the Pi as user pi , obsidian-sync pushes it to Obsidian Sync within seconds, and it’s on my laptop before I’ve put the phone down. The whole thing is one Python file, one Dockerfile, two compose services and a GitHub OAuth app. If you have Obsidian Sync and something at home that’s always on, the gist https://gist.github.com/mrmartineau/475dc3e8ffc6908f1493a05989a116ff has the full README with the step-by-step and a troubleshooting table for every way I broke it while setting it up.