obsidian-vault-mcp
Integrates with Gitea through webhooks to keep the vault working tree current, automatically pulling remote changes and pushing agent-written note updates back to the Gitea remote.
Provides search, read, and write access to an Obsidian vault, including hybrid/semantic/fulltext/title search, note retrieval with links and backlinks, and saving or updating notes in the vault's inbox.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@obsidian-vault-mcpSearch my vault for notes about MCP servers and summarize the top results."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
obsidian-vault-mcp
One MCP server for an Obsidian vault: search, read, and write, plus a Gitea webhook receiver that keeps the server-side working tree current.
Search and read are delegated to
obsidian-hybrid-search,
which this process supervises as a child on loopback. OHS's own MCP server is
read-only — search, read, reindex, status — so agents can find notes but
never record one. Wrapping rather than forking it keeps
npm install -g obsidian-hybrid-search@latest a one-line upgrade.
desktops (Obsidian + Obsidian Git plugin)
│ push / pull
▼
Gitea ──webhook──► obsidian-vault ──git pull --rebase──► /vault
│ ▲
├── save_note / update_note ───────┘
│
└── search_notes / read_notes
│
▼ (loopback, child process)
obsidian-hybrid-search serve
│
one MCP endpoint ──► AgentThe vault is a git working tree that both sides write to: desktops push through
Gitea, agents write through save_note. Every git operation runs behind a
single lock, and pushes this process makes are recorded so the webhook they
trigger is dropped instead of looping.
Tools
Tool | Purpose |
| Hybrid / semantic / fulltext / title search, similar-note lookup, link-graph traversal. Scope, tag, and rerank options. |
| Fetch notes in full by vault-relative path, with links and backlinks. |
| Write a new note into |
| Revise a note in the inbox: replace the body, merge frontmatter keys, or drop them with |
| Pull from the remote immediately. Rarely needed — the webhook handles this. |
| Vault path, branch, HEAD, inbox note count. |
Related MCP server: Obsidian MCP
Writable scope
save_note always writes into INBOX_DIR. update_note is bounded by
WRITABLE_PATHS, which defaults to that same inbox — so out of the box an
agent can revise notes it wrote and nothing else.
Widen it when you want agents maintaining curated notes as well:
WRITABLE_PATHS=ai-inbox,Networking,Projects. means the whole vault. Whatever the scope, .git, .obsidian, .ai-tmp,
and .trash are always refused — a note edit should never be able to reach
your plugin config or your repo internals, and that rule does not have an
override.
Note that update_note replaces the body wholesale; it is not a patch. An
agent revising a note it did not write should read_notes first.
Swapping the search backend
SearchBackend in backends.py is the seam: a five-method protocol
(start/stop/search/read/reindex). OHSBackend implements it,
NullBackend disables it, and the MCP tools never reference OHS directly.
This is deliberately an adapter rather than a generic tool proxy. A proxy that
re-exported OHS's tool list verbatim would be shorter, but it would make OHS's
schema the public API. With an adapter, replacing OHS means one new class and one env var;
agents that learned search_notes keep working.
The backend talks JSON-RPC to a long-lived serve process rather than shelling
out per query. Measured on a trivial vault: ~515 ms per cold CLI invocation
versus ~10 ms per call against the running server. Node startup, database open,
and the embedding-endpoint probe are paid once, not per search.
Every note gets id (ULID), title, created, and status: active. Once
they're there, superseding a note is update_note(path, frontmatter={"status": "superseded", "superseded_by": "<id>"})
and every agent query becomes --frontmatter -status:superseded.
Error handling
Expected failures — a bad path, a missing note, an unreachable search backend,
a vault the container cannot write to — are raised as ToolError, so the
message reaches the agent verbatim instead of the SDK's generic
"Error executing tool ". Anything unexpected still logs a full traceback
in the container; docker logs is the place to look when a message is generic.
Per-request transport chatter from the SDK ("Terminating session: None", one
per tool call, since stateless mode assigns no session id) and the HTTP client
is suppressed at LOG_LEVEL=INFO. Set LOG_LEVEL=DEBUG to see it.
When provisioning cannot produce a usable repo, git is disabled with the reason
recorded rather than left to explode on the first write. vault_status reports
git_problem and every save_note returns it alongside the saved path. Search
and write keep working.
Git failures are reported, not raised. By the time a commit runs, the note is
already on disk, so raising would make an agent retry and write a duplicate on
every attempt. save_note returns the path plus
"git": {"status": "error", "error": "..."} and the next successful sync picks
the note up.
pytest tests/ covers all of the above.
Setup
1. Vault dataset
zfs create perfpool/obsidian
zfs set atime=off perfpool/obsidian
mkdir /mnt/perfpool/obsidian/vaultThat's all. Create an empty repo in Gitea, set GIT_REMOTE_URL, and the
server provisions the rest at startup — git init, the .gitignore, the
remote, the initial commit, and the first push. Set GIT_AUTO_INIT=false to
manage the repo yourself.
What it does depends on what it finds, and only unambiguous cases are automatic:
Vault | Remote | Action |
empty or unversioned | empty |
|
empty | has history | clone |
has files, no | has history | stop and report |
already a repo | any | fix the remote URL, check out the branch |
The third row is a merge decision, not a provisioning step — reaching for
--allow-unrelated-histories on someone's knowledge base is not a choice a
daemon should make at startup. It leaves your files untouched and tells you to
clone elsewhere and merge by hand, or empty the vault so it can clone.
The generated .gitignore matters more than it looks. OHS writes its SQLite
index to .obsidian-hybrid-search.db inside the vault root; committing it
means three machines writing a multi-hundred-megabyte binary to the same path.
2. Remote authentication
HTTP(S) with a token (recommended; no SSH needed). In Gitea: Settings →
Applications → Generate New Token, scope write:repository. Then set
GIT_REMOTE_URL and either GIT_TOKEN or GIT_TOKEN_FILE.
Gitea accepts an access token as the HTTP password with any username. The
entrypoint writes it to a 0600 credential file under /state and configures
credential.helper=store, so the token stays out of .git/config, out of
git remote -v, and out of the process list — unlike the common
https://user:token@host/repo.git shortcut, which leaks it into all three.
credential.useHttpPath=false means one entry covers every repo on that host.
GIT_TERMINAL_PROMPT=0 is set, so a bad or missing token fails immediately
instead of hanging on a password prompt.
If Gitea sits behind an internal CA rather than a public cert, mount the CA
bundle and set GIT_SSL_CAINFO. Don't reach for GIT_SSL_NO_VERIFY.
SSH (alternative). Generate a keypair, add the public half as a write
deploy key, and mount the private half at /home/app/.ssh with a known_hosts
entry. The key must be owned by the UID you run as, mode 0600.
3. Build note
onnxruntime-node's postinstall downloads CUDA binaries from api.nuget.org.
If your build host has restricted egress, the image build fails there. The
Dockerfile passes --onnxruntime-node-install=skip; nothing is lost, because
OHS never uses the CUDA execution provider (see below).
Also worth knowing: OHS degrades rather than dies when the embedding endpoint is unreachable — it logs a warning, disables semantic search and indexing, and keeps serving fulltext and title search. An Ollama outage costs you recall, not availability.
4. Run
cp .env.example .env
openssl rand -hex 32 # -> GITEA_WEBHOOK_SECRET
docker compose -f docker-compose.example.yml up -d --build
curl -s http://obsidian-vault:3940/healthz5. Gitea webhook
Repo → Settings → Webhooks → Gitea:
Target URL:
https://obsidian-vault.internal.example.com/hooks/giteaHTTP Method: POST, Content Type:
application/jsonSecret: the value of
GITEA_WEBHOOK_SECRETTrigger: Push events only
Gitea's delivery log is your debugging surface — it shows the request, response
body, and status. Every response echoes the ref and after it saw, so the log
explains itself:
202 with
queued— the pull was scheduled.401 — the secret doesn't match.
200 with
skipped— another branch, this service's own push coming back around, or a branch that no longer exists on the remote.
An all-zero after is not treated as a branch deletion on its own. Gitea's Test
Delivery button and a push to a repo with no commits both produce one, so the
server checks ls-remote and syncs anyway when the branch is still there.
scripts/gitea-post-receive.sh is a fallback if the Gitea container can't reach
the service over HTTP. Prefer the webhook.
6. Open WebUI
Register one server:
https://obsidian-vault.internal.example.com/mcp
It speaks MCP Streamable HTTP natively, so mcpo is pure overhead here.
7. Desktops
Obsidian Git plugin, pointed at the same Gitea repo. Set auto-commit and auto-pull to 2–5 minutes. Nothing else to install per client.
Reranking is CPU-only
--rerank cannot use a GPU. reranker.js hardcodes device: 'cpu' with
dtype: 'int8', and the embedder does the same with dtype: 'q8'. There is no
env override. The upstream comment explains why: CUDA and CoreML don't support
the quantized ONNX opsets, so device: 'auto' silently falls back to fp32 and
blows up to roughly 36 GB of memory.
Embedding model note
Ollama defaults num_ctx to 2048. OHS chunks by heading, so a long section is
silently truncated at embed time with no error and no way to pass the parameter
through. Build a tagged model instead:
FROM bge-m3
PARAMETER num_ctx 8192ollama create bge-m3-8k -f ModelfileThen set OPENAI_EMBEDDING_MODEL=bge-m3-8k.
BGE-M3 over the higher-scoring Qwen3-Embedding models for a specific reason:
Qwen3 is instruction-aware and needs Instruct: <task>\nQuery: <query> prefixing
to reach its benchmark numbers, and OHS makes a plain /v1/embeddings call with
no prefixing. BGE-M3 is symmetric and prefix-free, so it performs as advertised
through a generic client. Recover precision with OHS's --rerank instead.
Changing the embedding model requires reindex --force on every index.
Running as an arbitrary UID
The image defaults to 3000:3000 but works under any user: "<uid>:<gid>",
so you can match whatever owns the vault dataset without rebuilding.
Two things break in a container whose UID has no /etc/passwd entry, and the
entrypoint handles both:
getpwuid()fails. The OpenSSH client exits with "You don't exist, go away!" before it ever contacts the remote, so git-over-SSH dies. The entrypoint detects an unknown UID and usesnss_wrapperto synthesize passwd and group entries viaLD_PRELOAD— no writable/etc/passwdrequired, which is what the usualchmod g+w /etc/passwdtrick needs and which only works when the GID happens to be 0.Git refuses the repo. A working tree owned by another UID trips "detected dubious ownership". The entrypoint writes
safe.directoryinto a gitconfig under/stateand pointsGIT_CONFIG_GLOBALat it, rather than$HOME, which may not be writable.
Everything the process writes lives under /state or $HOME, both 0777 in
the image. Use the named volume for /state; it inherits that mode on first
use. If you bind-mount a host path there instead, chown it to the UID you run
as or the container exits at startup with a clear message rather than failing
later in a confusing way.
One thing the image cannot fix: the SSH deploy key must be readable by the
UID you run as, and OpenSSH still refuses group- or world-readable private
keys. Chown the mounted key to that UID with mode 0600. If that is awkward,
use an HTTPS remote with a token instead — nothing else here depends on SSH.
Release
git tag v0.2.0 && git push --tagsDevelopment
python -m venv .venv && .venv/bin/pip install -e . pytest pytest-asyncio ruff
PYTHONPATH=src .venv/bin/python -m pytest tests -qRun without git for local experimentation:
VAULT_PATH=/tmp/vault GIT_ENABLED=false .venv/bin/python -m obsidian_vault_mcpLicense
MIT
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.
No tool schema history has been recorded yet.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Search and reason over your Obsidian-style Markdown vault, right from ChatGPT.
Search your Obsidian vault to quickly find notes by title or keyword, summarize related content, a…
Connect AI assistants to your GitHub-hosted Obsidian vault to seamlessly access, search, and analy…
Agent-native notes, tasks, dev-docs, vaults, sync & handoffs. MCP + OpenAPI dual surface.
Related MCP Servers
- FlicenseNot gradedqualityNot gradedmaintenanceEnables AI assistants to read, write, search, and navigate Obsidian vault notes with support for CRUD operations, full-text search, graph navigation, daily notes, and frontmatter management.4,785-
- AlicenseNot gradedqualityDmaintenanceEnables AI agents to manage Obsidian vaults through full CRUD operations, wikilink management, and section-level manipulation. It supports frontmatter editing, tag-based searching, and automated link updates to maintain vault integrity.MIT
- FlicenseNot gradedqualityCmaintenanceEnables reading, writing, searching, and managing Obsidian vault notes through MCP tools and prompts, allowing AI agents to interact with local knowledge bases.-
- -licenseNot gradedqualityNot gradedmaintenanceProvides AI agents with comprehensive access to Obsidian vaults, enabling reading, writing, searching, tagging, linking, canvas manipulation, and semantic search through 41 tools.-
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/RyanMelena/obsidian-vault-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server