Skip to main content
Glama
pitslug

Obsidian-MCP-Server

by pitslug
README.md
# Obsidian-MCP-Server

An MCP server that makes an Obsidian vault synced through
[Self-hosted LiveSync](https://github.com/vrtmrz/obsidian-livesync) readable and
writable by Claude, by speaking directly to the CouchDB that already backs that
sync.

The vault is never materialised as files. There is no second copy of the notes
on disk for another program to serve - this understands LiveSync's storage
format natively and exposes that understanding as MCP tools.

See [`docs/design.md`](docs/design.md) for the full design.

## Status

**In progress.** A read-only vertical slice runs end to end: it replicates a
real vault, indexes it and serves it over MCP, with search. Every write path to
the vault is still to come.

| Unit | State |
| --- | --- |
| Vault model | Verified against a live vault |
| Replicator | Implemented - pull-only, decoding at the boundary |
| Note parsing | Frontmatter, tags, wikilinks, headings |
| Index | SQLite with FTS5: search, properties, tags, link graph |
| Attachments | PDF text extraction, indexed and retrievable |
| Handwriting | Transcriptions stored durably, and indexed |
| Tool layer | Thirteen tools, none of which writes to the vault |
| Transport | stdio and streamable HTTP, bearer token |
| Deployment | Dockerfile and Compose for the target stack |
| Write executor | Not started, deliberately |
| OAuth 2.0 + PKCE | Not started - bearer token in the interim |

342 tests. `scripts/verify-vault.ts` can be pointed at a live vault read-only;
see below.

## Running it

```bash
npm install && npm run build

COUCHDB_URL='https://user:password@couchdb.example.net/?db=obsidiandb' \
MCP_TRANSPORT=stdio \
REPLICA_PATH=./tmp/replica \
node dist/index.js
```

It reads the vault's storage settings from the vault itself, replicates the
whole database locally, waits for that first pass, then serves. `deploy/`
holds a Dockerfile and a Compose file for a Traefik-fronted stack.

On stdio the server then waits for a client to speak MCP over stdin, which
from a terminal is indistinguishable from a hang. To see it work:

```bash
COUCHDB_URL='https://user:password@couchdb.example.net/?db=obsidiandb' npm run try
```

That starts the server, connects to it as a real MCP client, and calls each
tool in turn.

Configuration is environment variables throughout. Every sensitive value also
accepts a `NAME_FILE` form naming a file to read it from, which is how Docker
secrets are consumed. `deploy/obsidian-mcp.env.example` lists all of them.

### Why writes are absent rather than disabled

`READ_ONLY` defaults to true, but more to the point the write tools are not
registered at all. A tool that exists and reports "not implemented" is worse
than no tool: a model will call it, and the user will conclude that writing is
one setting away.

`save_transcription` is the one tool that stores something, and it stays
available under `READ_ONLY` because there is no code path from it to CouchDB.
The read-only setting is about the vault; a transcription is written to a local
database this server owns, and the original file is never touched. An
integration test asserts that by reading the attachment's document revision back
out of CouchDB after a transcription is saved.

## Handwritten notes

The vault this was built for is largely handwritten: pages of ink exported as
PDFs by an Obsidian plugin. Those files have no text layer at all, so extraction
finds nothing in them and OCR does badly on cursive. The transcriber that works
is a model reading the page - which this server cannot do, because it has no
model. What it can do is hold the page up and keep what comes back.

```
list_untranscribed   what has no searchable text, and what has gone out of date
get_attachment       hands over the PDF itself when there is no text to extract
save_transcription   stores the reading; it is searchable immediately
```

Transcriptions live in their own SQLite file, not in the index, and that
separation is the important design decision. The index is a cache: it is dropped
and rebuilt whenever its schema moves, and everything in it can be recomputed
from the replica. A transcription cannot be recomputed by anything. It is the
only data in the system whose loss is permanent, and the rest of its handling
follows from that:

- **Its own file, and never dropped by a rebuild.** An integration test starts a
  server, saves a transcription, throws the index and the replica away, starts
  again, and asserts the handwriting is still searchable. Deleting the fifteen
  lines that read the store back makes that test fail, which is the point: the
  suite previously passed with those lines removed entirely.
- **`journal_mode = DELETE`, against the usual advice.** WAL keeps recent
  commits in a sidecar file until a checkpoint, so a file-level nightly backup
  can copy a database missing its newest rows, or a torn one. Writes here are a
  few a day, so there is no throughput to trade away for a single
  self-contained file. `synchronous = FULL` for the same reason.
- **Superseded text is kept.** The likeliest way to lose a transcription is not
  a crash, it is a second, worse reading overwriting a first: a model that runs
  out of room after page one of a forty-page notebook will call
  `save_transcription` with a fragment quite happily. What it replaced goes to a
  history table in the same transaction.
- **A schema it does not recognise refuses to open.** The index answers that
  question by dropping everything and rebuilding, which is the one response
  unavailable here. A server that will not start is recoverable.

Each transcription records the size and modification time of the file it was
made from. Add another line of ink to a page and the transcription is reported
as out of date rather than left in the index quietly describing an older version
of it.

Renaming a file in Obsidian is a delete and a create, so a transcription keyed
on the old path comes unattached. Nothing deletes it, but `list_untranscribed`
and the startup log both report it, so the page does not silently reappear in
the queue with the reading already paid for sitting unreachable beside it.

## Why the vault model came first

It is the unit whose failure is unrecoverable. Everything else can be wrong in
ways you notice: a replication bug stalls, an index bug returns nothing, a tool
bug throws. A vault model bug writes a note that comes back subtly altered, and
you find out weeks later.

So it is built as pure functions - no network, no database handle, documents in
and a note out - which is what allows it to be tested three ways:

- **Round-trip property tests.** Splitting a note into chunks and reassembling
  it is the identity function, across empty files, very large files, unicode,
  astral-plane characters, mixed line endings, byte order marks, and content
  sized at every boundary the chunker derives.
- **Differential tests against the plugin itself.** `@vrtmrz/livesync-commonlib`
  is a dev dependency, so the suite runs our chunker, our path mapping, our
  compression and our document transform against the code every other device is
  actually running, and requires agreement. This is the strongest evidence
  available short of a real vault.
- **Failure-mode tests.** Most of the assembly suite asserts that the *error*
  happens - a missing chunk, an undecoded document, a payload that is not valid
  base64 - rather than that the success does.

## What the model owns

```
src/vault-model/
  constants.ts    Storage-format literals, each with its upstream source
  types.ts        Document shapes as they exist in CouchDB
  settings.ts     Format settings, and reading them from the vault's own milestone
  ids.ts          Path ↔ document ID, normalisation, obfuscation
  hash.ts         Chunk identity, all five hash algorithms
  chunking/       The V3 Rabin-Karp content-defined chunker
  crypto.ts       The E2EE boundary (delegates to octagonal-wheels)
  compression.ts  The deflate layer
  transform.ts    Wire form ⇄ plain form
  assemble.ts     Documents in, file out
  compose.ts      File in, documents out
```

[`docs/livesync-storage-contract.md`](docs/livesync-storage-contract.md) records
the storage format this was derived from, including the parts that look like
bugs and have to be reproduced anyway.

## Findings so far

Three things the design flagged as assumptions, now settled:

- **Chunk IDs are pure content hashes.** No per-document, per-revision or
  per-position salt. The write path can reuse chunks across notes and treat a
  409 on a chunk PUT as success, exactly as the design hoped.
- **Path obfuscation is one-way, but the plaintext path is in the document.**
  Recovering a path from an ID is impossible; recovering it from the decrypted
  `path` field is routine. Writing to an existing obfuscated path means
  recomputing the identical hash, so path normalisation has to match Obsidian's
  exactly - including NFC composition and non-breaking-space folding.
- **The E2EE v2 metadata seal is not idempotent.** Encrypting an
  already-encrypted document destroys its chunk list irrecoverably while leaving
  a document that still reads cleanly as an empty note. Guarded, and tested
  against the plugin's own transform.

Two things deliberately not implemented, and why:

- **The V1 and V2 chunk splitters.** Chunk boundaries do not affect
  correctness - the plugin reassembles whatever it is given - only
  deduplication. Implementing two more splitters to save bandwidth on a vault
  that almost certainly uses the current default is not worth the surface area.
  Writing to such a vault throws unless `allowSplitterFallback` is set.
- **`eden` inline chunks.** An obsolete optimisation. Documents carrying them
  are rejected with a message saying so, rather than reporting their chunks as
  missing and leaving the caller unable to satisfy a request that never can be.

## Verifying against a real vault

`scripts/verify-vault.ts` points at a live LiveSync database and checks that
this code understands it. It is **read-only by construction** - the only
request method in the file is `GET`, and a test asserts that a full run issues
nothing else - so it is safe against a production vault.

```bash
npm run verify -- --url 'https://user:password@couchdb.example.net/?db=obsidiandb'
```

It reports the vault's own format settings (read from the milestone document,
not assumed), flags any setting two devices disagree on, assembles a sample of
notes, and then does the check that matters: re-chunks each note and compares
the chunk IDs it *would* write against the ones the plugin actually wrote. If
those match, the write path deduplicates exactly as another device does.

`--attachments` reports, per attachment, whether its text can be extracted at
all. That is the question that decides whether attachment search is worth
anything for a given vault: a PDF of handwriting is searchable only if
something already put a text layer into it. OneNote's handwriting recognition
does; a scanner without OCR does not. Run it before assuming either way.

`--census` walks the entire ID space instead and reports where every document
went: counts by type, live versus deleted files, notes versus hidden-file sync,
and how much of the chunk store is orphaned. Most documents in a LiveSync
database are chunks, not notes, so `doc_count` is a poor proxy for vault size - the census is what tells you the difference.

Useful flags: `--all` to verify every file in the vault rather than a sample,
`--sample N`, `--passphrase` for an encrypted vault, `--verbose` for per-file
output, and `--capture out.json` to save real documents as fixtures - that file
contains note content, so do not commit it.

The sample is spread evenly across the whole ID range rather than taking the
first N, because IDs sort by path and attachments cluster at one end. It also
reports the text/binary split and warns when a run contained no binary files at
all, so a green result cannot quietly mean "attachments untested".

## Development

Requires Node 22 or later.

```bash
npm install
npm test           # everything
npm run test:diff  # just the differential tests against the plugin
npm run typecheck
```

The differential tests import `@vrtmrz/livesync-commonlib` by explicit file
path, because its package exports map does not expose the modules involved. That
import lives in `test/helpers/upstream.ts` and must never appear in `src/` - the
point of the vault model is that it owns this logic rather than borrowing it.

## Licence

MIT.