Skip to main content
Glama
musicman12091

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.