obsidian-vault-mcp
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 |
|
| Sorted vault-relative note paths ( |
|
| Full contents of one note (text formats, ≤ 512 KB) |
|
| Case-insensitive literal substring matches (never regex), ±2 context lines |
|
| Parsed YAML frontmatter as JSON; |
Propose (v2)
Tool | Arguments | Effect |
|
| Drafts a change to an existing note → patch in staging. Refuses if a |
|
| Drafts a new |
| — | Lists staged proposals (newest first): id, kind, status, target, source. |
|
| Returns a proposal's manifest, human summary, and diff. |
|
| Writes an append-only |
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.jsVAULT_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.jsJSON 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.patchIf 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 PATH33 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.