Skip to main content
Glama
musicman12091

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 apply fail-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 — cp overwrites blindly.

  • The append-only ledger/decisions.md and CHANGELOG.md are 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.

Related MCP server: Obsidian MCP Server

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 mandatory source frontmatter field). Missing/empty source is 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_note scaffolds status/confidence/updated/source/supersedes, defaulting status: provisional, confidence: low.

  • propose_edit auto-bumps the note's updated: to today (disable with bump_updated: false).

  • supersedes: for an in-place edit it stays none — 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_ROOT and refuses to start if it equals, contains, or is contained by VAULT_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 under PROPOSALS_ROOT and not under VAULT_ROOT before touching disk.

  • propose_new_note targets must not exist (else it routes you to propose_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 apply and reproduces the server's preview exactly;

  • fail-closed edits (absent find, ambiguous multi-match), mandatory source, 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 status before 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 where graph.json churns).

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 cellpropose_edit with one replacements entry whose find is the exact table row. The diff shows precisely which cell moved, and updated: auto-bumps. (Verified end-to-end in the tests against this file.)

  • Add HF Pro / Copilot Pro rowspropose_edit inserting a row: set find to an existing adjacent row and replace to that row plus the new row, so the edit is anchored and unambiguous. Or add a companion note with propose_new_note stack/huggingface-pro.md.

  • Reconcile scattered renewal dates → several replacements in one propose_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 bare find: "\[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 Renewal column across every row), that's a large multi-row rewrite better done as a single new_content proposal (full-table replacement) so the diff is coherent — replacements shine for point edits, new_content for 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 startupVAULT_ROOT missing/not a dir, or PROPOSALS_ROOT overlaps 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/unique find or an exact count.

  • git apply --check fails on-device — the note drifted from the proposal's base; re-read and re-propose.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

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/musicman12091/obsidian-vault-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server