Obsidian MCP Server

by maxkuminov

Not rated
GitHub

About

Self-hosted MCP server for Obsidian: semantic + full-text search, wikilink graph, note CRUD, OAuth, and a self-describing vault guide.

Details

Author
maxkuminov
Categories
AI, Other, Knowledge Base, Search

Setup

Install Obsidian MCP Server in your MCP client (Claude Desktop, Cursor, Windsurf, and others).

Repository: https://github.com/maxkuminov/obsidian-mcp

Follow the installation instructions in the repository README, then restart your MCP client.

A self-hostedModel Context Protocolserver that turns your Obsidian vault into shared memory between you and your AI agents. Indexed, searchable, and self-describing — agents read what you read, link what you link, and pick up your folder layout, frontmatter schema, and tag conventions on the first call instead of being briefed from scratch every session.

Stack: Python 3.12, FastAPI, PostgreSQL with pgvector. Pluggable embeddings (Ollama bge-m3, or OpenAItext-embedding-3-{small,large}).

- Why this exists
-
A session at the keyboard
-
What's in the box
-
vs. other Obsidian MCP servers
-
Who this is for
-
Control panel
-
Quick start
-
Cost expectations
-
The self-describing vault
-
Multi-user mode
-
Configuration
-
Architecture
-
Project layout
-
Development
-
Security notes

There are three things going on here, and they're more interesting together than apart.

1. A shared memory layer between you and your agents

I think of my Obsidian vault as my exocortex. The "big me" that includes notes, calendars, scripts, search, and AI assistants is substantially more capable than the "small me" of the biological brain alone. It's also where I do most of my thinking, because writing something down is itself a form of thought.

The problem is that until recently, the vault was passive. I had to go find things. Agents that wanted to help me had to be briefed from scratch every session, and they had no way to see what I'd already written about a topic.

This server fixes that. Now the same vault feeds my own daily writing and any agent I plug into it. The agent reads what I read, links what I link, follows the same wikilinks, sees the same frontmatter. When I write a project note on Sunday, my Monday-morning briefing agent already knows about it. When the agent leaves notes from a research session, they show up in my normal Obsidian search.

A concrete version of this: I'll spend a session in Claude Code on a project, wrap up, push the commits, and then just say "update Obsidian." The agent reads the vault guide, figures out where project notes live in my structure, picks the right format and frontmatter, and leaves a session log I can later roll into a status report. No path-passing, no telling it what to write — the conventions are already in the vault, and it follows them.

That's the exocortex idea made concrete: one place that holds context, and both the human and the agents reading and writing into it on the same terms.

2. Agent memory that you can actually read

The other half is the inverse. If you let an agent run for a while, it needs memory. Most setups solve this with an opaque vector store, a SQLite blob, or a managed "memory" service that you can't see into. That works until you want to know what the agent thinks it knows about you, or you need to correct something, or you want to understand why it just made a weird suggestion.

This server gives you a different deal. Agent memory lives as markdown files in your vault. Folder structure, file names, frontmatter, all visible. You can open the file in Obsidian and read it. You can edit it. You can delete it. You can grep it. The agent's "memory" is a human-auditable artifact that sits in the same place as your own notes, with the same tools available.

The home lab is the use case that sold me on this. My vault has notes on the rack, the network, and every Home Assistant integration. I can say "set up a night-light mode in the master bathroom, 1% after 11pm" and a sysadmin agent finds the right config, makes the change, and updates the doc in the same pass. Six months later when I've forgotten how it works, the answer is in the vault, not buried in some chat history I can't search.

The semantic search and wikilink graph still work over that material, so retrieval is fast and conceptual. But the substrate is files you own, not a black box.

The thing that still surprises me is that this is internet-facing. Same vault, same notes, reachable from anywhere I happen to be talking to an agent — Claude in a browser tab, Claude on my phone on the train, an n8n workflow firing on a schedule, a Claude Code session on whatever laptop is in front of me. All behind OAuth or a bearer key, all hitting the same source of truth.

So when I hear an interesting podcast on a commute and want to think out loud about it with Claude on my phone, the agent isn't starting cold. It can pull up what I've already written on adjacent topics, surface a note I half-finished six months ago, and at the end of the conversation suggest updates and write them in. The vault doesn't have to be near me to be the thing I'm working in.

To make the abstract concrete, a short transcript from a real session. Tool calls inline; the user types in plain English and the agent picks the right tool.

you > find anything I've written about why retrieval-augmented chat is hard claude → semantic_search(query="why retrieval-augmented chat is hard") • 2025-12-03 - RAG failure modes.md • 2025-10-18 - Chunking tradeoffs.md • 2024-06-22 - Vector recall vs precision.md the closest hit is your December note on RAG failure modes. let me see what it's connected to. → get_neighborhood(path="2025-12-03 - RAG failure modes.md", depth=1) 8 connected notes. notable ones: • "Hybrid search" — a re-ranking design you sketched but didn't ship • "Out-of-distribution queries" — a half-finished draft from October • "Embedding model choice" — links to bge-m3, your current model you have three connected pieces here: a failure-modes catalog, an unshipped hybrid-search sketch, and an abandoned O-O-D draft. want me to summarize all three together, or pull up the draft so you can decide whether to finish it? you > pull up the draft, and tag it #revisit-2026 in frontmatter while you're there claude → read_note(path="2025-10-18 - OOD queries (draft).md") → set_frontmatter( path="2025-10-18 - OOD queries (draft).md", updates={"tags": ["draft", "rag", "revisit-2026"]} ) here's the draft, and the tag is on. you stopped right before the section on confidence thresholds; the open question you left yourself was…

Two things to notice. First, the agent didn't need to be told what folder to look in or what tools to use — it picked them. Second, the write at the end is structured (set_frontmattermutating YAML, not a regex over the file body), so the note round-trips cleanly. The self-describing vault and the wikilink graph are doing the work that makes this feel natural.

The server exposes 20 MCP tools across six concerns.

- keyword_search(query, folder?, tags?, frontmatter?, limit=20), full-text via PostgreSQLtsvector; the text-search config(s) are configurable viaFTS_CONFIGS(seeFull-text search language(s))
- semantic_search(query, folder?, tags?, frontmatter?, limit=15), vector similarity via pgvector, one preview chunk per note
- list_notes(folder?, limit=50), sorted by modified time
- get_recent(folder?, limit=20), recently changed
- get_tags(limit=50), tag and count
- get_vault_guide(), the Obsidian primer plus this vault'sCLAUDE.md, served live

- read_note(path, section?, offset=0, limit?), bounded byMAX_READ_RESPONSE_CHARS(default 40,000) — seeResponse size limits.section=<heading>returns one section instead of the whole note;offsetcontinues a truncated read.
- create_note(path, content), atomic write, refuses overwrite
- edit_note(path, …)with four mutually exclusive modes: full replace (default),append=True,find=…(with optionalreplace_all), orsection=<heading>(ATX headings, supportsParent/Childpath-style and#Nordinal disambiguation).dry_run=Truereturns a unified diff without writing. Legacy clients may useoperation="append";operation="replace"explicitly selects full replace.
- move_note(from_path, to_path, rewrite_links=False), relocates and optionally rewrites incoming
[[Old]],[[Old|alias]],[[Old#anchor]],![[Old]], and[[folder/Old]]references in source notes
- delete_note(path, permanent=False), soft-delete to.trash/<YYYYMMDD-HHMMSS>-<basename>by default.permanent=Truedoes a hardos.unlink.
- set_frontmatter(path, updates, remove?), structured YAML mutation. Body is byte-identical when only frontmatter changes.

Raw read/write/browse of arbitrary vault files (PDFs, images, skill assets, data files) — distinct peers to the note tools, which stay markdown-only. Pure byte transport: no server-side PDF/text extraction, no embedding or indexing of non-markdown files.

- read_file(path, encoding="auto", offset=0, limit?), returns text-like files as text, images as an inline image block that renders in-client, and other binaries as a base64 string.text/base64force the form. Refuses files overMAX_FILE_READ_BYTES(default 10 MB); text results are additionally bounded byMAX_READ_RESPONSE_CHARSand continue viaoffset.
- write_file(path, content, encoding="base64", overwrite=False), lands a file in the vault; base64 for binary,textfor UTF-8. No-clobber by default, auto-creates parent dirs, atomic write. Capped atMAX_FILE_WRITE_BYTES(default 25 MB).
- list_files(folder=".", pattern="", recursive=False, limit=200),ls-style browse of files and subdirectories with size and mtime, glob-filterable and result-capped.
- delete_file(path, permanent=False), soft-deletes a non-markdown file to.trash/<YYYYMMDD-HHMMSS>-<basename>-<8 hex>with a single atomic rename. Refuses markdown (that isdelete_note), directories, and symlinks.

All four reuse the path-traversal guard and exclude dot-directories (.obsidian,.git,.trash, …), matching the indexer's visibility rule.

No MCP client can hand a tool the bytes of a file the user is looking at, sowrite_fileis only usable when the agent already has the content. These tools close that gap with short-lived capability links, redeemed over the public/transfer/routes.

- request_upload(path, overwrite=False, expires_in?), mints a single-use link bound to exactly one destination path. The human opens it, picks a file, and it lands atpath— nothing else can be written with it.
- check_upload(upload_id), reportspending/uploading/completed(with size, sha256 and MIME) /expired, scoped to the identity that minted it.
- request_download(path, expires_in?), mints a link the human can save one vault file from. Usable more than once until it expires, and bound to the file's exact bytes at mint time.
- import_from_url(url, path, overwrite=False), fetches a public https asset straight into the vault under an explicit outbound deny policy (no private, loopback, link-local, metadata or tunnelled addresses, in any spelling, re-checked at every redirect).

The token travels in the URLfragment, which browsers never send, so no server-generated request target or access log contains it. Uploads are claimed before a body byte is read, published atomically with no-clobber semantics, and bound at mint time to the file state they were minted against — a link cannot silently undo an edit made while it was waiting.MCP_HOSTNAMEorBASE_URLmust be set; without a public origin the mint tools refuse rather than emit a localhost link.

- get_backlinks(path, limit=50), notes linking TOpath
- get_links(path), outgoing links, both resolved and dangling
- get_neighborhood(path, depth=1, limit=50), undirected BFS over the resolved-link graph, capped at depth ≤ 5 and limit ≤ 200
- find_related(path, limit=10), semantic neighbors via averaged chunk embeddings and pgvector cosine distance, deduped per note
- find_orphans(folder?, limit=50), notes with zero in or out resolved links

- API keys with theomcp_prefix, stored as SHA-256 hashes, withreadandreadwritepermission scopes. Write tools refuse on read-only keys.
- OAuth 2.0 PKCE (S256) flow for public and confidential clients, including ChatGPT, Claude Desktop, and claude.ai. Dynamic registration defaults to both vault permission levels; the user chooses the actual grant on the consent screen.
- Control panel (Jinja2, htmx, Tailwind) for keys, usage logs, indexer status, embedding-provider info, and a danger-zone reset.
- Every tool call is logged tousage_logswith name, params (truncated to 200 chars), duration, and response size.

All write tools route throughsrc/services/vault.py::write_file, which writes to a tmp file in the same directory andos.replace()s it onto the destination. A crash mid-write cannot truncate a note.

There are several existing MCP servers for Obsidian, and most of them solve a different problem than this one. The lightweight ones are glue over Obsidian's Local REST API plugin or the filesystem: they let an agent reach the files, but don't build any infrastructure of their own. They're great if "I just want Claude to read my notes" is the goal and you keep Obsidian running locally.

This server is on the other end of the spectrum: a real backend with a persistent index, semantic retrieval, a wikilink graph, OAuth, and an admin UI. The cost is Postgres and Docker. The benefit is everything you can build on top of that.

Comparison reflects each project's documented features at time of writing; verify the specifics before betting on them.

- Homelab folks who already run Postgres and Docker, or are happy to spin them up. The setup tax is the price of admission for the semantic and graph layers.
- People who keep an opinionated vault — task placement logic, frontmatter schemas, tag taxonomy — and want agents to follow those conventions on the first call instead of being briefed every session.
- Anyone running more than one MCP client (Claude Desktop, Claude Code, Claude in a browser, n8n) against the same notes and tired of re-explaining the vault to each.
- Folks who want agent memory to live as plain markdown files they can read, edit, grep, and version-control, not in an opaque vector store or a managed memory service.

- "I just want Claude to read my notes" with the lightest possible setup. Use one of the filesystem-glue projects above; you don't need this.
- Anyone unwilling to run a database. There is no SQLite fallback; pgvector is doing real work, and a managed Postgres with pgvector support is part of the stack.
- People who want a turnkey hosted product. This is a self-hosted server you run yourself.

The server ships with a built-in admin UI for the parts of operations that are easier to look at than to query: minting keys, watching the indexer, eyeballing tool-call traffic, and resetting embeddings when you switch providers.

Per-tool-call audit log with a 14-day request histogram. Every MCP call is recorded with the calling key, tool name, duration, and response size — useful for noticing a misbehaving agent burning tokens on something it shouldn't.

Bearer keys withread/readwritescopes for API clients, and a separate OAuth 2.0 PKCE flow for clients like ChatGPT, Claude Desktop, and claude.ai that expect a proper authorization-code dance. The OAuth server supports public (none) and confidential (client_secret_post) token-endpoint authentication plus refresh tokens.

A read-only file tree of the mounted vault, mostly for sanity-checking that the container sees what you think it sees.

Indexer status, current embedding provider and model, vault path, and the danger-zone reset that drops and recreates the embeddings column at the configured dimension. Use this when switching providers.

Deploying on a VPS from scratch? SeeDEPLOYMENT.mdfor the full walkthrough: Postgres setup, Caddy and TLS, vault sync via Nextcloud, and the gotchas that bite first-time deploys.

The bundled Caddy configuration fails closed on/admin,/api, and/authorize; replace its placeholder basic-auth hash before starting it.

- Docker and Docker Compose
- A PostgreSQL 16 instance reachable from the container, with thepgvectorextension installed
- Either an Ollama instance runningbge-m3, or an OpenAI API key. Anything that speaks the OpenAI embeddings protocol works (Azure OpenAI, OpenRouter, Together, etc.).

1. Clone, configure, point at your vault

git clone https://github.com/maxkuminov/obsidian-mcp.git cd obsidian-mcp cp .env.example .env $EDITOR .env

Indocker-compose.yml, point the/obsidianvolume at your vault:

volumes: - /path/to/your/vault:/obsidian
EMBEDDING_PROVIDER=openai OPENAI_API_KEY=sk-... EMBEDDING_DIMENSIONS=1024 OPENAI_EMBEDDING_MODEL=text-embedding-3-small

The server validatesOPENAI_API_KEYat startup and refuses to boot if it's missing.

Option B, Ollama (self-hosted, GPU recommended):

EMBEDDING_PROVIDER=ollama OLLAMA_URL=http://your-ollama-host:11434 EMBEDDING_MODEL=bge-m3 EMBEDDING_DIMENSIONS=1024

This is the default. OmittingEMBEDDING_PROVIDERfalls back to Ollama.

make init # data dirs and .env from template (skip if you've already edited) make db-init # create database, user, and pgvector extension make deploy # build, push to local registry, run migrations, recreate container

The first deploy backfills the index, the wikilink graph, and the embeddings. For a 2 to 3k-note vault on Ollama with a GPU this takes a few minutes. Ontext-embedding-3-smallit's seconds.

Mint an API key in the control panel, then point your MCP client at:

URL: https://obsidian-mcp.<your-domain>/mcp Auth: Bearer omcp_...

For Claude Desktop, add toclaude_desktop_config.json:

{ "mcpServers": { "obsidian": { "url": "https://obsidian-mcp.<your-domain>/mcp", "headers": { "Authorization": "Bearer omcp_..." } } } }
claude mcp add obsidian --transport http \ --url "https://obsidian-mcp.<your-domain>/mcp" \ --header "Authorization: Bearer omcp_..."

The first thing any agent should do in a new session is callget_vault_guide(). That's how it learns your folder structure, naming conventions, and YAML schema before it writes anything.

If you go the OpenAI route (the realistic path on a CPU-only VPS), the first-index spend is small and the steady state is nearly free. Rough numbers assuming an average note around 1,500 tokens (three 512-token chunks), at OpenAI's published rate at time of writing:

After the first index, only changed notes are re-embedded. Ongoing cost is proportional to edits — pennies a month for a typical vault.

If you self-host Ollama with a GPU, embedding cost is whatever your power bill is. Ollama on CPU works but is too slow to be usable on a vault of more than a few hundred notes.

This is the part most "MCP for Obsidian" projects miss. They stop at read, write, and list. The interesting question isn't "can the agent reach the files," it's "does the agent know the rules?"

If you have an opinionated vault — task placement logic, folder conventions, required frontmatter, tag taxonomy — an agent with write access can do real damage without that context. Tasks land in the wrong folder. Bare-date filenames collide with templates. Wrong tags break Dataview queries. The data layer works fine; the context layer is where the failures show up.

The fix is small. Keep a machine-readable instruction file (CLAUDE.mdat the vault root) that describes the system's own rules. Expose it as a dedicated tool. Every connecting agent calls it once at the start of a session and immediately knows how the vault works. Update the file, every agent sees the change on the next call. No client-side config. No system-prompt injection. The vault is authoritative about its own rules.

get_vault_guide()does exactly this. It returns a generic Obsidian primer (wikilink syntax, embed syntax, tag conventions, common plugin literals) plus the vault'sCLAUDE.mdlive. The hint to call it first is baked into the write-tool descriptions so the agent gets pulled into the right behavior even without prompting.

Single-user mode is the default and works exactly as described above — one vault, one set of API keys, no in-app user concept. Multi-user mode is an opt-in flag that turns the same container into a small multi-tenant deployment: in-app username/password login, per-user vault scoping, an admin role for troubleshooting, and a regular-user role that sees only its own keys/OAuth clients/usage. One container, one Postgres, strict isolation between users.

Enable it on an existing deployment with no data loss — your current vault and keys carry over to the bootstrap admin.
- SetMULTI_USER_MODE=trueand a strongSECRET_KEYin.env(openssl rand -hex 32is fine). The app refuses to start with the placeholder value when the flag is on.
- make deploy(ordocker compose up -d --force-recreate).
- Visit the panel. Because theuserstable is empty, you're routed to/admin/register— the one-time bootstrap form. It's still behind Traefik'schain-oauth@filemiddleware, so only people Traefik already trusts can claim admin.
- Register with a chosen username and password. The bootstrap form pre-fillsvault_pathwith whateverVAULT_PATHwas set to, so your existing notes immediately belong to this new admin. No re-index, no re-embed, no data loss — every previously indexed note, API key, OAuth client, and usage log row gets backfilled to the bootstrap user in a single transaction.
-

Editdocker-compose.ymlto add a volume mount for the new user's vault under/vaults/<username>. Host paths with spaces must be quoted as a single YAML string:

volumes: - "/storage/vaults/alice:/vaults/alice" - "/storage/shared/bob/Obsidian:/vaults/bob"

In the panel,/admin/users/create— pick a username and set an initial password.

/admin/users/{id}/edit— set the user'svault_pathto the container path you just mounted (e.g./vaults/bob). The form shows a dropdown of unassigned/vaults/*directories that exist on disk.

Share the credentials out-of-band. The user logs in at/admin/auth/login, gets their own keys/OAuth/usage views, and cannot see other users' notes.

Admins see API keys, OAuth clients, and usage logs for all users; they own the Settings page (embedding provider, indexer trigger, danger zone) and the Users page. Admins donotbrowse other users' vault contents through the panel — that's intentional. Troubleshooting another user's vault means either inspecting it viadocker execor temporarily reassigning theirvault_path, not UI snooping.

- The indexer iterates active users sequentially each cycle. Fine for tens of users; hundreds would need parallelization.
- Password reset is admin-driven only — there's no email-based self-service flow.
- No rate limiting on/admin/auth/login. The Traefik OAuth gate in front of the panel is the main brute-force defense; if you expose/admin/auth/loginto the open internet, put a rate-limit middleware in front of it.
- Thevault_pathvalidator does not resolve symlinks, so an admin can technically point a user at host files via a symlinked/vaults/<name>. Treat/vaults/as an admin-trust boundary.

No reviews yet — be the first

Sign in to leave a review

Use Google, GitHub, or an email account so ratings stay tied to real people.

Email sign in

No reviews posted yet.