Skip to main content
Glama
jamessturges

Obsidian MCP Server

by jamessturges
README.md
# Obsidian MCP Server

A remote [MCP](https://modelcontextprotocol.io) server that exposes a folder of Markdown notes — an Obsidian vault, or just a plain folder of `.md` files — to LLM clients like Claude and ChatGPT. It talks Streamable HTTP, operates directly on the filesystem, and has no dependency on the Obsidian app itself.

```
MCP client --HTTP--> server.mjs (127.0.0.1:MCP_PORT) --node:fs--> VAULT_ROOT
```

The server only binds to `127.0.0.1`. It does not do TLS termination, authentication, or expose itself to the network — that's a separate concern, left to whatever you put in front of it (see [Exposing it remotely](#exposing-it-remotely)).

## What it can do

| Tool | Scope |
| --- | --- |
| `obsidian_search` | Full-text search across the vault (or a folder), with context, folder scoping, case sensitivity, and per-file match counts |
| `obsidian_list_notes` | List files/folders at a path |
| `obsidian_read_note` | Read any note; `include_metadata` adds frontmatter, tags, headings, word count |
| `obsidian_read_notes` | Batch-read up to 20 notes in one call; a missing note errors per-path, not the whole batch |
| `obsidian_recent_notes` | List the most recently modified notes, newest first |
| `obsidian_vault_tree` | Folder tree overview, to orient without many list calls |
| `obsidian_query_notes` | Structured metadata search: filter by folder, tags, frontmatter fields, or modified date |
| `obsidian_list_tags` | All tags in use (frontmatter + inline `#tag`) with usage counts, to avoid inventing near-duplicate tags |
| `obsidian_list_tasks` | Checkbox inventory (`- [ ]` / `- [x]`) across the vault, with folder/status filters |
| `obsidian_get_backlinks` | Outgoing links + backlinks for a note, resolved the way Obsidian resolves `[[wikilinks]]` |
| `obsidian_create_inbox_note` | Create a note in `Inbox/` (fails if it exists) |
| `obsidian_append_inbox_note` | Append to a note in `Inbox/` (creates if absent) |
| `obsidian_append_daily_note` | Append to today's (or a given date's) daily note, creating it if absent |
| `obsidian_capture_inbox` | Save a standardized capture (conversation, excerpt, decision, todo, etc.) into `Inbox/` |
| `obsidian_update_note` | Edit an existing note anywhere in the vault — full replace, exact-string replace, or insert under a heading — fails if the note doesn't exist |
| `obsidian_move_note` | Move/rename a note anywhere in the vault, rewriting every other note's `[[wikilink]]` to it (alias/heading/embed preserved) — fails if the destination exists |
| `obsidian_delete_note` | Soft-delete a note into `.trash/` (reversible); reports notes that link to it so you know what's now dangling |

Most of the metadata tools (`query_notes`, `list_tags`, `list_tasks`, `get_backlinks`) ride on one internal vault index that walks the vault once and caches parsed frontmatter/tags/headings/links/tasks per file, keyed by `(path, mtime)`, so repeat calls only re-parse notes that actually changed.

## Write model

- **New notes** can only be created inside `Inbox/` (configurable). The one exception is `obsidian_append_daily_note`, which may create today's daily note outside `Inbox/` because that path is fully deterministic and the write is append-only.
- **Editing, moving/renaming, and deleting** existing notes is allowed anywhere in the vault.
- **Delete is soft**: `obsidian_delete_note` moves the file into `.trash/` instead of unlinking it, so it's always recoverable by hand. `.trash/` is excluded from every listing/search tool.
- **Move is wikilink-safe**: `obsidian_move_note` rewrites every other note's `[[wikilink]]`/`![[embed]]` that resolves to the moved note (bare filename if that's still unique, otherwise the full path), preserving aliases and `#heading` anchors.
- **Path safety**: every tool resolves paths through one canonical helper that rejects absolute paths, `..` traversal, and symlinks pointing outside `VAULT_ROOT`.
- **Writes are atomic**: temp file + rename in the same directory, so a client watching the folder (Obsidian, a sync client) never sees a partial write.

## Configuration

Copy `.env.example` to `.env` and fill in:

| Variable | Default | Meaning |
| --- | --- | --- |
| `VAULT_ROOT` | — (required) | Absolute path to the vault folder. |
| `MCP_PORT` | `3001` | Port the server listens on (loopback only). |
| `INBOX_FOLDER` | `Inbox` | Where new-note creation is confined to. |
| `CAPTURE_WRITE_JSON_SIDECAR` | `true` | Whether `obsidian_capture_inbox` also writes a `.json` sidecar next to the Markdown. |
| `CAPTURE_EMBED_RAW_JSON` | `false` | Whether to embed the raw capture payload as a JSON block in the Markdown. |
| `CAPTURE_COLLISION_BEHAVIOR` | `suffix` | `suffix` \| `overwrite` \| `error` — what to do if a capture's generated filename already exists. |
| `DAILY_NOTE_FOLDER` / `DAILY_NOTE_FORMAT` | — | Fallback for `obsidian_append_daily_note` only if `.obsidian/daily-notes.json` doesn't exist (the server reads your real Daily Notes plugin settings first). |

## Running it

```bash
npm install
cp .env.example .env   # set VAULT_ROOT
node server.mjs
```

```bash
curl -s http://127.0.0.1:3001/health   # {"ok":true}
```

That's the whole server: one Node process, no database, no build step. `npm run start` does the same thing.

## Exposing it remotely

Claude web/mobile and ChatGPT connect to MCP servers over the network, not over stdio like Claude Desktop — so using this from those clients means putting a real HTTPS URL with authentication in front of `127.0.0.1:MCP_PORT`. This server has no authentication of its own; that's intentionally left to whatever sits in front of it. `/mcp` should be behind that auth layer; `/health` is safe to leave open, it returns nothing but `{"ok":true}`.

Once the client can reach the URL, add it as a custom connector (Claude) or a developer-mode connector (ChatGPT — standard ChatGPT connectors only invoke tools literally named `search`/`fetch` and won't show any actions here, since this server intentionally doesn't add those aliases).

## A note on cloud-synced vaults

If `VAULT_ROOT` lives on a cloud-synced drive (iCloud Drive, Dropbox, OneDrive, etc.), a file can be an evicted placeholder that isn't actually on disk yet. `readFileSync` on one either blocks while it downloads or errors. The search/read tools handle this per-file — an unreadable note is reported individually rather than failing the whole call — but if it becomes a real problem, opening the file once in the desktop app (or your sync client's "keep local copy" option) forces it to materialize.

## Development

The server is split into `server.mjs` (tool registration + Streamable HTTP transport) and `lib/`:

- `lib/config.mjs` — env loading and validated config constants
- `lib/vault-fs.mjs` — `resolveInVault`, atomic writes, vault walking, ignore rules
- `lib/markdown.mjs` — frontmatter/heading/task/wikilink/tag parsing (fence-aware)
- `lib/vault-index.mjs` — the cached vault-wide index and link resolution/backlink graph
- `lib/daily-notes.mjs` — reads `.obsidian/daily-notes.json`, Moment-token date formatting
- `lib/capture.mjs` — the `obsidian_capture_inbox` payload normalizer and Markdown renderer

`scripts/smoke-test.mjs` drives the MCP surface end-to-end (`initialize` → `tools/list` → `tools/call`) against a scratch vault, without needing a real client:

```bash
VAULT_ROOT=/tmp/fake-vault node server.mjs &
node scripts/smoke-test.mjs
```

It exercises every tool above, including path-traversal rejection, wikilink rewriting on move, and dangling-backlink reporting on delete.

## License

MIT — see [LICENSE](LICENSE).