Skip to main content
Glama
RyanMelena

obsidian-vault-mcp

by RyanMelena

obsidian-vault-mcp

ci docker

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 ──► Agent

The 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

search_notes

Hybrid / semantic / fulltext / title search, similar-note lookup, link-graph traversal. Scope, tag, and rerank options.

read_notes

Fetch notes in full by vault-relative path, with links and backlinks.

save_note

Write a new note into ai-inbox/. Server assigns id, filename, timestamp. Commits and pushes.

update_note

Revise a note in the inbox: replace the body, merge frontmatter keys, or drop them with null.

sync_vault

Pull from the remote immediately. Rarely needed — the webhook handles this.

vault_status

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/vault

That'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

git init, write .gitignore, commit, push

empty

has history

clone

has files, no .git

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/healthz

5. Gitea webhook

Repo → Settings → Webhooks → Gitea:

  • Target URL: https://obsidian-vault.internal.example.com/hooks/gitea

  • HTTP Method: POST, Content Type: application/json

  • Secret: the value of GITEA_WEBHOOK_SECRET

  • Trigger: 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 8192
ollama create bge-m3-8k -f Modelfile

Then 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 uses nss_wrapper to synthesize passwd and group entries via LD_PRELOAD — no writable /etc/passwd required, which is what the usual chmod g+w /etc/passwd trick 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.directory into a gitconfig under /state and points GIT_CONFIG_GLOBAL at 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 --tags

Development

python -m venv .venv && .venv/bin/pip install -e . pytest pytest-asyncio ruff
PYTHONPATH=src .venv/bin/python -m pytest tests -q

Run without git for local experimentation:

VAULT_PATH=/tmp/vault GIT_ENABLED=false .venv/bin/python -m obsidian_vault_mcp

License

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues

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

Related MCP Servers

  • F
    license
    Not graded
    quality
    Not graded
    maintenance
    Enables 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
    -
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables 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
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables reading, writing, searching, and managing Obsidian vault notes through MCP tools and prompts, allowing AI agents to interact with local knowledge bases.
    -
  • -
    license
    Not graded
    quality
    Not graded
    maintenance
    Provides 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

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