obsidian-vault-mcp
README.md
# 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`](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)
```bash
# 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).
```bash
# 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:
```bash
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`):
```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`.
```sh
# 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
```bash
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
making `find` the **whole row** (unique), or pass an exact `count`. 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.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues