obsidian-vault-mcp

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.

Category
Visit Server

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 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.

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.

Recommended Servers

playwright-mcp

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.

Official
Featured
TypeScript
Magic Component Platform (MCP)

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.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

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.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

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.

Official
Featured
TypeScript
Kagi MCP Server

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.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

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.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured