obsidian-vault-mcp
Read and propose changes to an Obsidian vault through git-apply-able diffs, with a security model that prevents the AI from ever writing to the vault directly.
README
obsidian-vault-mcp
A read + propose MCP server for an Obsidian vault. Stdio transport, official
@modelcontextprotocol/sdk. It exposes four read-only tools (list / read /
search / frontmatter) and a propose side: an AI can draft changes to the vault
as git apply-able diffs written to an external staging dir — while remaining
physically incapable of modifying the vault.
Built for a "one-writer" vault: only the human commits to it, via git. The
server preserves that rule structurally — proposals are computed from guarded
vault reads and written only under PROPOSALS_ROOT. No code path writes,
creates, deletes, or moves anything under the vault root. Proven on Android
sdcardfs under Termux/proot — see docs/android-sdcardfs-field-notes.md.
Why diffs, not staged files
Chosen from the vault's own doctrine (FOUNDATION.md, ledger/, CHANGELOG.md):
- The vault is git-native — "No filename versioning… git holds every past state… Rollback is one command." A unified diff is the git-native artifact: it shows what changed (verify), applies in one command, reverts in one.
- The human stays out of the git plumbing — verifies, doesn't hand-merge. A
patch is reviewable at a glance and applied with a single
git apply. - The one-writer rule is reinforced by diffs:
git applyfail-closes on context drift, so a stale or fabricated proposal that doesn't match current canon refuses to apply rather than clobbering. A full-file staging copy has no such guard —cpoverwrites blindly. - The append-only
ledger/decisions.mdandCHANGELOG.mdare naturally diff hunks; frontmatter bumps (updated/status/supersedes) are small inline edits — exactly what diffs express precisely.
The AI never hand-writes diff hunks. It supplies intent (a target path plus literal find/replace edits, or full new content). The server reads the current note through v1's guarded read path, computes the result in memory, and emits a correct unified diff. Hallucinated line numbers are impossible.
Tools
Read (v1 — unchanged)
| Tool | Arguments | Returns |
|---|---|---|
list_notes |
dir? |
Sorted vault-relative note paths (.md .txt .yaml .yml .json .base .canvas), capped at 2000 |
read_note |
path |
Full contents of one note (text formats, ≤ 512 KB) |
search_notes |
query (2–256), dir? |
Case-insensitive literal substring matches (never regex), ±2 context lines |
get_frontmatter |
path (.md) |
Parsed YAML frontmatter as JSON; null when absent; malformed YAML is an error |
Propose (v2)
| Tool | Arguments | Effect |
|---|---|---|
propose_edit |
path, source, replacements[] or new_content, rationale?, bump_updated?, supersedes? |
Drafts a change to an existing note → patch in staging. Refuses if a find is absent or ambiguous (fail-closed). |
propose_new_note |
path, source, title?, body?, status?, confidence?, supersedes?, rationale? |
Drafts a new .md note (scaffolds frontmatter, status: provisional). Refuses if the path already exists. |
list_proposals |
— | Lists staged proposals (newest first): id, kind, status, target, source. |
read_proposal |
id |
Returns a proposal's manifest, human summary, and diff. |
discard_proposal |
id, reason? |
Writes an append-only .discarded tombstone — no file is deleted or moved. |
replacements are literal (no regex), matching v1's search discipline. Each
edit must match its expected occurrence count exactly (default 1) or the whole
proposal is refused — ambiguous means no.
Provenance & frontmatter (anti-taint)
The vault's rule: "nothing enters canon without stating where it came from… everything starts provisional and earns its way up." So:
- Every propose call requires a
source(mirrors the mandatorysourcefrontmatter field). Missing/emptysourceis refused. - Each proposal records a base SHA-256 of the pre-image the diff was computed
against, plus kind, target, timestamp, rationale, and
status: proposed— a proposal is never canon. propose_new_notescaffoldsstatus/confidence/updated/source/supersedes, defaultingstatus: provisional,confidence: low.propose_editauto-bumps the note'supdated:to today (disable withbump_updated: false).supersedes: for an in-place edit it staysnone— git is the supersession record (one file edited over time). Set it only when a proposal retires a different note, naming that note.
What a staged proposal looks like
$PROPOSALS_ROOT/
20260722-184147-bfa2e9/
manifest.json # id, kind, target, source, rationale, baseSha256, status: proposed
change.patch # git apply-able unified diff (the canonical artifact)
proposal.md # human-readable: intent, provenance, apply commands, the diff
preview/canon/inventory.md # full proposed content, for eyeballing (review aid)
Security model
Everything from v1 carries forward — realpath containment, refusal of absolute
paths / ~ / backslashes / null bytes / doubled slashes / any .. segment,
case-insensitive dot-segment denial (.git/.obsidian/.trash and .Git on
case-insensitive Android storage), symlink-escape refusal, uniform refusals,
fail-closed on ambiguity, no regex. v2 adds:
- Staging must live outside the vault. At startup the server resolves
PROPOSALS_ROOTand refuses to start if it equals, contains, or is contained byVAULT_ROOT. The overlap check runs on the resolved intended path before any directory is created, so a staging path pointed inside the vault is rejected without ever writing under the vault root. - Single write choke-point. Every filesystem write funnels through
stagingWrite()/stagingMkdir(), which assert the target is underPROPOSALS_ROOTand not underVAULT_ROOTbefore touching disk. propose_new_notetargets must not exist (else it routes you topropose_edit), and a symlinked parent that escapes the vault is refused.
Grep-able audit (no vault-write call sites)
# 1. server.js imports only READ fs APIs, and has ZERO write call sites:
grep -n 'node:fs' server.js
# -> import { realpath, stat, readdir, readFile } from "node:fs/promises";
grep -nE '(writeFile|appendFile|mkdir|rmdir|unlink|rename|copyFile|chmod|createWriteStream|symlink)\s*\(' server.js
# -> (no output)
# 2. propose.js does write — but ONLY inside the staging helpers, each gated by
# an assert that refuses paths under the vault:
grep -nE 'await (writeFile|mkdir)\(' propose.js # 4 calls, all in staging helpers
grep -n 'refusing write under VAULT_ROOT' propose.js # the guard
# 3. Neither file can shell out (no git/exec that could reach the vault):
grep -nE 'child_process|execSync|spawn\(' server.js propose.js # (no output)
The test suite also proves it at runtime (see below).
Install (Termux → proot-Debian on Android)
Prereqs: termux-setup-storage run once; proot-distro binds /storage (default).
# inside proot-distro debian — Node >= 18 (SDK); tested on Node 24
apt update && apt install -y nodejs npm git
cp -r /storage/emulated/0/Vault_1/mcp-vault ~/mcp-vault # copy OUT of the vault
cd ~/mcp-vault
npm install
# staging lives OUTSIDE the vault; default ~/oc-vault-proposals
mkdir -p ~/oc-vault-proposals
# smoke test — prints "[mcp-vault] ready (read + propose)" on stderr; Ctrl-C
VAULT_ROOT=/storage/emulated/0/Vault_1 PROPOSALS_ROOT=~/oc-vault-proposals node server.js
VAULT_ROOT defaults to /storage/emulated/0/Vault_1; PROPOSALS_ROOT defaults
to ~/oc-vault-proposals. The server exits nonzero if the vault is missing or if
staging overlaps the vault.
Register with Claude Code
CLI form:
claude mcp add vault \
--env VAULT_ROOT=/storage/emulated/0/Vault_1 \
--env PROPOSALS_ROOT=/root/oc-vault-proposals \
-- node /root/mcp-vault/server.js
JSON form (.mcp.json, or the mcpServers block of ~/.claude.json):
{
"mcpServers": {
"vault": {
"command": "node",
"args": ["/root/mcp-vault/server.js"],
"env": {
"VAULT_ROOT": "/storage/emulated/0/Vault_1",
"PROPOSALS_ROOT": "/root/oc-vault-proposals"
}
}
}
}
Use absolute paths (/root is the proot-Debian home). PROPOSALS_ROOT must be
outside the vault.
Review & apply workflow (you are the one writer)
The AI stages a proposal; you review and apply it from Termux. Nothing
reaches the vault without your git commit.
# 1. See what's been drafted
ls ~/oc-vault-proposals # or ask the AI: list_proposals
cat ~/oc-vault-proposals/<id>/proposal.md # intent, provenance, and the diff
# 2. Get the vault to the base state, then dry-run the patch (fails closed on drift)
cd /storage/emulated/0/Vault_1
git pull
git apply --check ~/oc-vault-proposals/<id>/change.patch
# 3. Apply, eyeball the result, then commit — only you commit to the vault
git apply ~/oc-vault-proposals/<id>/change.patch
git diff # verify
git add -A && git commit -m "apply proposal <id>: <target>"
# Reject instead? Just don't apply it. Optionally tombstone it:
# (ask the AI: discard_proposal <id>) — writes a marker, deletes nothing
# Undo an applied-but-uncommitted patch:
# git apply -R ~/oc-vault-proposals/<id>/change.patch
If git apply --check fails, the note drifted from the proposal's base — re-read
the note and re-propose. That refusal is the one-writer rule doing its job.
Tests
VAULT_ROOT=/path/to/Vault_1 npm test # Node >= 22.22.3 recommended; git on PATH
33 tests. Beyond v1's read + deny + symlink-escape coverage, v2 proves:
- a proposal targeting a vault path lands in staging and the vault file is byte-identical (SHA-256 compared before/after);
- the staged patch applies cleanly with
git applyand reproduces the server's preview exactly; - fail-closed edits (absent
find, ambiguous multi-match), mandatorysource, new-note frontmatter scaffolding, refusal of existing paths, and the v1 path guard on propose tools; - startup refuses when staging equals / is inside the vault, and the rejected new-subdir is never created under the vault;
- a snapshot-diff of
git statusbefore vs. after the propose runs is identical — proving the tools added nothing to the vault, without assuming a pristine tree (so it holds on a live vault, e.g. on-device wheregraph.jsonchurns).
The suite builds all fixtures under the OS temp dir — it never writes in the vault.
First real use — auditing the subscription burn record
The first target is the stale subscription/cost record. In this vault that is
canon/inventory.md — a markdown table with [fill] cost placeholders, a
Status column, and companion per-service notes under stack/*.md. The stale
items you flagged (HF Pro and Copilot Pro missing, placeholder costs, scattered
renewal dates) map cleanly onto v2:
- Fill a
[fill]cost cell →propose_editwith onereplacementsentry whosefindis the exact table row. The diff shows precisely which cell moved, andupdated:auto-bumps. (Verified end-to-end in the tests against this file.) - Add HF Pro / Copilot Pro rows →
propose_editinserting a row: setfindto an existing adjacent row andreplaceto that row plus the new row, so the edit is anchored and unambiguous. Or add a companion note withpropose_new_note stack/huggingface-pro.md. - Reconcile scattered renewal dates → several
replacementsin onepropose_edit, each targeting one exact cell.
What suits it, and one gap to know:
- Suits it well: surgical, auditable, one-writer-safe cell edits; provenance on every change; the whole audit arrives as one reviewable patch.
- Gap — occurrence anchoring:
[fill]appears in several rows, so a barefind: "\[fill]`"is refused as ambiguous (by design). Anchor each edit by makingfindthe **whole row** (unique), or pass an exactcount`. This is friction, not a blocker — it's the fail-closed guard preventing a wrong-row edit. - Gap — new columns / restructures: if the audit wants a schema change (e.g. a
new
Renewalcolumn across every row), that's a large multi-row rewrite better done as a singlenew_contentproposal (full-table replacement) so the diff is coherent —replacementsshine for point edits,new_contentfor restructures. - Not v2's job: fetching real billing amounts. v2 drafts and records provenance; it does not know your actual costs. Supply the numbers (or point the AI at billing sources); v2 turns them into a reviewable patch.
Troubleshooting
- Server exits at startup —
VAULT_ROOTmissing/not a dir, orPROPOSALS_ROOToverlaps the vault. The stderr line names the cause. ERROR: path refused …— path is outside the root, contains.., or touches a dot-directory. By design.ERROR: … "find" occurs N×— your edit is ambiguous; use a longer/uniquefindor an exactcount.git apply --checkfails on-device — the note drifted from the proposal's base; re-read and re-propose.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.