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
访问服务器

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 cell → propose_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 rows → propose_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 startup — VAULT_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.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选