obsidian-headless-mcp
by seb7152
README.md
# Obsidian Headless + MCP Server
Complete deployment of Obsidian Headless with a REST API wrapper and MCP server for remote access via HTTPS.
## Architecture
```
Internet
↓
Traefik (reverse proxy + SSL/TLS)
├─ obsidian-api.yourdomain.com → Node.js REST API
└─ mcp.yourdomain.com → Python MCP server
↓
Obsidian Headless (syncs with Obsidian Sync)
↓
Your vault files ←→ SQLite index (vault-indexer)
```
## Services
### 1. **Traefik**
Reverse proxy with automatic SSL/TLS (Let's Encrypt). Routes HTTPS traffic to services.
### 2. **Obsidian Headless**
Synchronizes your vault from the command line using Obsidian Sync (end-to-end encrypted). Stores files in `./vault`.
### 3. **Obsidian API** (Node.js)
REST API wrapping vault file operations. All endpoints require `Authorization: Bearer <API_TOKEN>` except `/health`. Exposed at `https://obsidian-api.DOMAIN`.
### 4. **Vault Indexer** (Node.js)
Embedded SQLite index kept in sync with the vault via a file watcher. Indexes frontmatter, tags, and tasks from every `.md` file. Queried via `POST /api/query`. The same watcher drives **webhooks**, POSTing to external URLs when files change (see [Webhooks](#webhooks)).
### 5. **Hybrid Search Index** (Node.js)
Lives in the same SQLite file as the vault index. Every note is split into chunks and indexed twice: **BM25** through SQLite's FTS5, and **semantically** as a vector embedding. A query hits both and the two rankings are fused, so a note is found whether you remember its wording or only its meaning. An optional cloud **reranker** can reorder the top candidates. See [Hybrid search](#hybrid-search).
### 6. **MCP Server** (Python)
Model Context Protocol server exposing the vault as tools and resources to AI models. Exposed at `https://mcp.DOMAIN`.
## Prerequisites
- Docker & Docker Compose
- Obsidian Sync subscription
- Valid domain with DNS pointing to your server
- Obsidian account credentials
## Setup
### 1. Clone/Download Files
```
.
├── docker-compose.yml
├── .env (copy from .env.example)
├── obsidian-api.js
├── obsidian_mcp.py
├── vault-indexer.js
└── vault/ (created automatically)
```
### 2. Configure Environment
```bash
ACME_EMAIL=your-email@example.com
DOMAIN=yourdomain.com
OBSIDIAN_EMAIL=your-obsidian-email@example.com
OBSIDIAN_PASSWORD=your-account-password # Obsidian account password (for `ob login`)
VAULT_PASSWORD=your-vault-encryption-password # Vault encryption key (Obsidian → Settings → Sync → Encryption)
VAULT_NAME=Your-Vault-Name # Exact vault name in Obsidian Sync
API_TOKEN=your-secret-token # Root token for REST API + MCP auth
# (scoped, revocable tokens are minted from it)
```
### 3. Deploy
Paste `docker-compose.yml` into your host's Docker Compose editor, add the environment variables, and deploy. First start takes ~1 minute.
---
## REST API
Base URL: `https://obsidian-api.DOMAIN`
All endpoints require:
```
Authorization: Bearer <token>
```
Exception: `GET /health` is public.
The token is either the `API_TOKEN` from the environment (full access, not
revocable without a restart) or a **scoped API token** — named, revocable,
optionally read-only, path-restricted and expiring. See [API tokens](#api-tokens).
### Health
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/health` | Server health check (no auth required) |
```bash
curl https://obsidian-api.yourdomain.com/health
# → {"status":"ok","vault":"/vault"}
```
### Files — single file
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/file/{path}` | Read a file — returns `frontmatter`, `body`, and `content` |
| `POST` | `/api/file/{path}` | Write or create a file (full content replace) |
| `PATCH` | `/api/file/{path}` | Merge-update frontmatter fields (body untouched) |
| `PATCH` | `/api/file/{path}/body` | Replace body only (frontmatter untouched) |
| `PATCH` | `/api/file/{path}/patch` | Surgical text replace — swap `old_text` for `new_text`, rest untouched |
| `POST` | `/api/file/{path}/append` | Append content at end of file |
| `POST` | `/api/file/{path}/move` | Move file to a new path |
| `DELETE` | `/api/file/{path}` | Delete a file — soft by default (moved to `.trash/`); `?hard=true` removes it permanently |
| `GET` | `/api/file/{path}/links` | List broken wikilinks in a file (optionally with fuzzy suggestions) |
| `POST` | `/api/links/check` | List broken wikilinks in an arbitrary text snippet — not tied to a file, for checking just-changed text |
**Read a file**
```bash
curl -H "Authorization: Bearer $TOKEN" \
https://obsidian-api.yourdomain.com/api/file/notes%2Fmy-note.md
# → {"path":"notes/my-note.md","frontmatter":{...},"body":"...","content":"..."}
```
**Write a file**
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"# My Note\n\nContent here"}' \
https://obsidian-api.yourdomain.com/api/file/notes%2Fnew.md
```
**Update frontmatter only**
```bash
curl -X PATCH -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"done","reviewed":true}' \
https://obsidian-api.yourdomain.com/api/file/notes%2Fmy-note.md
```
**Append content**
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"## New Section\n\nAdded text."}' \
https://obsidian-api.yourdomain.com/api/file/notes%2Fmy-note.md/append
```
**Surgical text patch**
Replace a precise piece of text without rewriting the whole file. By default only the
**first** occurrence is replaced; pass `"replace_all": true` to replace every occurrence.
Omit `new_text` (or set it to `""`) to delete the matched text.
```bash
curl -X PATCH -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"old_text":"- [ ] Draft proposal","new_text":"- [x] Draft proposal"}' \
https://obsidian-api.yourdomain.com/api/file/notes%2Fmy-note.md/patch
# → {"success":true,"path":"notes/my-note.md","occurrences":1,"replacements":1,"replace_all":false,"changed":true}
```
Edge cases:
- `400` — `old_text` missing/empty, or `new_text` is not a string
- `404` — file does not exist
- `422` — `old_text` not found in the file (nothing is changed; the edit never applies silently)
**Move a file**
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"destination":"archive/my-note.md"}' \
https://obsidian-api.yourdomain.com/api/file/notes%2Fmy-note.md/move
```
**Delete a file**
```bash
# Soft delete (default) — moved to .trash/, recoverable
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
https://obsidian-api.yourdomain.com/api/file/notes%2Fmy-note.md
# → {"success":true,"deleted":"notes/my-note.md","mode":"soft","trashed_to":".trash/notes/my-note.md"}
# Permanent delete
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"https://obsidian-api.yourdomain.com/api/file/notes%2Fmy-note.md?hard=true"
# → {"success":true,"deleted":"notes/my-note.md","mode":"hard"}
```
Soft delete moves the file to a hidden `.trash/` folder at the vault root. That folder is **not indexed** (excluded from search/SQL like all dotfiles), and the deletion still fires the `unlink` webhook event. Trashed files are **auto-purged after `TRASH_RETENTION_DAYS` days** (default `30`; set to `0` to keep them forever) — the purge runs on startup and once a day, ageing files from when they were trashed.
**Check broken wikilinks**
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://obsidian-api.yourdomain.com/api/file/notes%2Fmy-note.md/links?suggest=true"
# → {"path":"notes/my-note.md","count":5,"broken_count":1,"broken_links":[{"raw":"...","target":"...","suggestions":["..."]}]}
```
Scans the whole file, frontmatter included — a `related: "[[Note]]"` field is checked the same as a `[[Note]]` in the body.
To check a text snippet instead of a file on disk (e.g. before writing it):
```bash
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"text": "See [[Some Note]]", "suggest": true}' \
https://obsidian-api.yourdomain.com/api/links/check
# → {"count":1,"broken_count":0}
```
### Files — bulk operations
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/files` | List all `.md` files with optional filters |
| `POST` | `/api/files/batch` | Read up to 100 files in one request |
| `PATCH` | `/api/files/batch` | Apply same frontmatter patch to up to 100 files |
| `POST` | `/api/files/move` | Move multiple files to a destination folder |
**List files with filters**
Query parameters (all optional):
- `path` — substring match on file path
- `since=YYYY-MM-DD` — only files created on or after this date
- `before=YYYY-MM-DD` — only files created on or before this date
- any frontmatter key — e.g. `status=done&type=note`
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://obsidian-api.yourdomain.com/api/files?status=reviewed&since=2025-01-01"
# → {"files":[{"path":"...","frontmatter":{...},"hasContent":true}],"count":12,"filters":{...}}
```
**Batch read**
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"paths":["notes/a.md","notes/b.md"]}' \
https://obsidian-api.yourdomain.com/api/files/batch
```
**Bulk frontmatter update**
```bash
curl -X PATCH -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"paths":["notes/a.md","notes/b.md"],"frontmatter":{"status":"archive"}}' \
https://obsidian-api.yourdomain.com/api/files/batch
```
**Bulk move**
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"paths":["inbox/note1.md","inbox/note2.md"],"destination_folder":"30_Knowledge"}' \
https://obsidian-api.yourdomain.com/api/files/move
```
### Folders
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/folders` | Create one or more folders (batch), including missing parent folders |
| `DELETE` | `/api/folders` | Delete one or more folders (batch), recursively |
| `POST` | `/api/folders/move` | Move or rename one or more folders (batch), each to its own destination |
**Create**
Body: `{ "paths": ["20_Projects/Alpha", "20_Projects/Alpha/Docs"] }` — up to 100 entries.
Useful for scaffolding a directory structure in one call. Creating a folder that
already exists is not an error.
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"paths":["20_Projects/Alpha","20_Projects/Alpha/Docs","20_Projects/Alpha/Assets"]}' \
https://obsidian-api.yourdomain.com/api/folders
# → {"results":[{"path":"20_Projects/Alpha","success":true,"already_existed":false},...],"count":3,"failed_count":0}
```
**Delete**
Body: `{ "paths": ["20_Projects/Alpha", "20_Projects/Beta"] }` — up to 100 entries.
Soft-deletes by default (each folder tree is moved into `.trash/`, recoverable);
pass `?hard=true` (or `{ "hard": true }` in the body) to remove permanently. The
vault root cannot be deleted this way.
```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"paths":["20_Projects/Alpha"]}' \
https://obsidian-api.yourdomain.com/api/folders
# → {"results":[{"path":"20_Projects/Alpha","success":true,"mode":"soft","trashed_to":".trash/20_Projects/Alpha"}],"count":1,"failed_count":0}
```
**Move / rename**
Body: `{ "moves": [{"from": "20_Projects/Alpha", "to": "20_Projects/AlphaRenamed"}] }` — up
to 100 entries. Each entry is an independent `{from, to}` pair (unlike bulk file
move, which relocates several files into one shared destination folder). Missing
parent folders in the destination are created automatically.
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"moves":[{"from":"20_Projects/Alpha","to":"20_Projects/AlphaRenamed"}]}' \
https://obsidian-api.yourdomain.com/api/folders/move
# → {"results":[{"from":"20_Projects/Alpha","to":"20_Projects/AlphaRenamed","success":true}],"count":1,"failed_count":0}
```
### Directory
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/directory` | List vault root (files and subdirectories) |
| `GET` | `/api/directory/{path}` | List a specific directory |
```bash
curl -H "Authorization: Bearer $TOKEN" \
https://obsidian-api.yourdomain.com/api/directory/20_Projects
# → {"path":"20_Projects","entries":[{"name":"ProjectA","path":"20_Projects/ProjectA","type":"directory"},...],"count":5}
```
### Search
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/search` | Search vault content (hybrid by default) |
| `GET` | `/api/search/status` | Chunk counts, embedding progress, provider health |
| `POST` | `/api/search/reindex` | Kick the background embedding worker |
Query parameters:
- `q` (required) — search term
- `mode` — `auto` (default) · `hybrid` · `semantic` · `bm25` · `grep` · `fuzzy`
- `rerank=true` — reorder candidates with the cloud reranker (off by default)
- `limit` — max notes returned (default 20, max 100)
- `path` — restrict to a vault folder prefix, e.g. `20_Projects`
- `since=YYYY-MM-DD` / `before=YYYY-MM-DD` — filter by note date
- `fuzzy=true` — legacy alias for `mode=fuzzy`
Which mode to use:
| Mode | What it does | Reach for it when |
|------|--------------|-------------------|
| `auto` | Hybrid if the semantic index is ready, else `bm25` | Almost always |
| `hybrid` | BM25 + vector, fused with RRF | Best general recall |
| `semantic` | Vector only | The query's wording won't appear in the notes |
| `bm25` | Ranked full-text only | Names, acronyms, jargon |
| `grep` | Literal substring, unranked | An exact string: an ID, a URL, a snippet |
| `fuzzy` | Title similarity | A half-remembered note name |
```bash
# Default: hybrid
curl -H "Authorization: Bearer $TOKEN" \
"https://obsidian-api.yourdomain.com/api/search?q=migration+du+serveur&since=2026-01-01"
# → {"query":"...","mode":"hybrid","results":[{"file":"...","title":"...","heading":"Bascule",
# "matches":["..."],"date":"2026-03-10","score":0.0328,
# "chunks":[{"heading":"Bascule","start_line":4,"end_line":18}]}],"count":3,"warnings":[]}
# Meaning only — finds "migration serveur" from "changement d'hébergeur"
curl -H "Authorization: Bearer $TOKEN" \
"https://obsidian-api.yourdomain.com/api/search?q=changement+d'hebergeur&mode=semantic"
# Hard query: pay for a reranking pass
curl -H "Authorization: Bearer $TOKEN" \
"https://obsidian-api.yourdomain.com/api/search?q=...&rerank=true"
# Exact string
curl -H "Authorization: Bearer $TOKEN" \
"https://obsidian-api.yourdomain.com/api/search?q=INC-4471&mode=grep"
```
`warnings` is not decoration: it is how a degraded answer announces itself
(`"semantic index not ready — answered with BM25 only"`). An empty `warnings`
array means the mode you asked for is the mode you got.
### SQL Query
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/query` | Run a SQL `SELECT` against the vault index |
The vault index has two tables:
**`files`**
| Column | Type | Description |
|--------|------|-------------|
| `path` | TEXT | Relative path from vault root |
| `title` | TEXT | Frontmatter `title` or filename |
| `created` | TEXT | Frontmatter `created` (YYYY-MM-DD) |
| `modified` | TEXT | Frontmatter `modified` or file mtime |
| `tags` | TEXT | JSON array of tags (frontmatter + inline `#tag`) |
| `frontmatter` | TEXT | Full frontmatter as JSON object |
**`tasks`**
| Column | Type | Description |
|--------|------|-------------|
| `file_path` | TEXT | Parent file path |
| `text` | TEXT | Task text (without the checkbox) |
| `completed` | INTEGER | `0` = open, `1` = done |
| `due` | TEXT | Due date YYYY-MM-DD (from `📅` or `due::` syntax), or null |
Only `SELECT` statements are allowed.
```bash
# Notes with status=active, most recent first
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"sql":"SELECT path, title, created FROM files WHERE json_extract(frontmatter, '\''$.status'\'') = '\''active'\'' ORDER BY created DESC LIMIT 10"}' \
https://obsidian-api.yourdomain.com/api/query
# Open tasks due in the next 7 days
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"sql":"SELECT file_path, text, due FROM tasks WHERE completed = 0 AND due <= date('\''now'\'', '\''+7 days'\'') ORDER BY due"}' \
https://obsidian-api.yourdomain.com/api/query
```
Useful JSON operators:
```sql
-- Filter by frontmatter field
WHERE json_extract(frontmatter, '$.status') = 'done'
-- Filter by tag
WHERE tags LIKE '%"project"%'
-- Extract nested field
SELECT path, json_extract(frontmatter, '$.priority') AS priority FROM files
```
### Projects
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/projects` | List all subdirectories of `20_Projects/Pro/` |
```bash
curl -H "Authorization: Bearer $TOKEN" \
https://obsidian-api.yourdomain.com/api/projects
# → {"projects":[{"name":"ProjectA","path":"20_Projects/Pro/ProjectA"}],"count":3}
```
### Agent Context
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/agent/context` | Read `agent.md` from vault root |
Returns the contents of `agent.md`, which can hold instructions or context for AI agents working with the vault.
### Sync
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/sync` | Trigger a vault sync with Obsidian Sync |
| `GET` | `/api/sync/status` | Get current sync status, plus SQLite index / file watcher health |
`/api/sync/status` also reports on the SQLite index and its file watcher —
useful because the watcher has occasionally stopped picking up changes
silently (e.g. inotify not propagating across Docker bind mounts), leaving
the index (and therefore `/api/search`, `/api/query`) stale without any
visible error.
```bash
curl -H "Authorization: Bearer $TOKEN" \
https://obsidian-api.yourdomain.com/api/sync/status
# → {
# "status": "...",
# "indexer": {
# "watcher_ready": true,
# "watcher_closed": false,
# "db_file_count": 842,
# "vault_file_count": 842,
# "in_sync": true,
# "last_event": {"type": "change", "path": "notes/a.md", "at": "2026-08-03T21:10:00.000Z"},
# "last_error": null
# },
# "search": {
# "chunks": 5120, "embedded": 5120, "pending": 0,
# "semantic_ready": true,
# "embed_provider": "local", "embed_model": "Xenova/multilingual-e5-small",
# "embed_error": null, "rerank_available": true
# }
# }
```
- `watcher_closed: true` or a persistently old `last_event.at` while files keep changing on disk is a strong signal the watcher died and needs a restart.
- `in_sync: false` means the indexed file count doesn't match the vault's actual `.md` file count — a full reindex (restart the service) will resync it.
- `search.semantic_ready: false` with a non-zero `search.pending` means the embedding backfill is still running; searches answer with BM25 in the meantime. A non-null `search.embed_error` means it is stuck, not slow.
### API tokens
Hand a caller its own credential instead of sharing `API_TOKEN`. Each token
carries scopes, path restrictions and an optional expiry, and can be revoked on
its own without disturbing anything else.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/tokens` | List tokens (metadata only — secrets are never returned) |
| `GET` | `/api/tokens/{id}` | Get one token |
| `POST` | `/api/tokens` | Create a token — the plaintext is in this response and nowhere else |
| `DELETE` | `/api/tokens/{id}` | Revoke, effective on the next request |
| `POST` | `/api/tokens/verify` | Resolve a token to its principal — used by the MCP server |
All four need the `admin` scope, which only `API_TOKEN` and tokens you
explicitly create with it have.
**Fields**
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | **Required.** What this token is for — it is what you will read when deciding whether to revoke it. |
| `scopes` | string[] | `read`, `write`, `admin`. Default `["read"]`. `write` implies `read`; `admin` implies both. |
| `path_allow` | string[] | Vault-relative prefixes this token may reach. Empty = whole vault. |
| `path_deny` | string[] | Prefixes it may never reach. **Deny always wins over allow.** |
| `expires_at` | `YYYY-MM-DD` | After this date the token stops authenticating. Must be a real calendar date — an impossible one would sort after every real date and never expire. Optional, but set one for anything living on a machine you don't fully control. |
Prefixes match on whole path segments, so `10_Context/Perso` covers
`10_Context/Perso/profil.md` but never `10_Context/Perso2/`. Paths are
canonicalised before the comparison — `.` and `..` are resolved, and a path
climbing above the vault root or carrying a backslash is refused outright, so a
prefix cannot be walked around with `20_Projects/Pro/../../10_Context/Perso/`.
A prefix that cannot be canonicalised is rejected when the token is created,
rather than silently dropped.
```bash
# A read-only token for a machine that should never see the personal zone
curl -X POST -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"work laptop","scopes":["read"],
"path_allow":["20_Projects/Pro","30_Knowledge"],
"path_deny":["10_Context/Perso"],
"expires_at":"2026-12-01"}' \
https://obsidian-api.yourdomain.com/api/tokens
# → {"token":"obsv_…","id":"tok_a1b2c3d4e5f6","name":"work laptop",…}
# Copy the token now — only its SHA-256 is stored, it cannot be shown again.
# See who is using what
curl -H "Authorization: Bearer $API_TOKEN" https://obsidian-api.yourdomain.com/api/tokens
# → each entry carries last_used_at and last_used_ip
# Revoke
curl -X DELETE -H "Authorization: Bearer $API_TOKEN" \
https://obsidian-api.yourdomain.com/api/tokens/tok_a1b2c3d4e5f6
```
**How restrictions are enforced**
- Endpoints addressing one path (`/api/file/{path}` and its `/append`, `/move`,
`/body`, `/patch`, `/links` variants, `/api/directory/{path}`) return `403`
with the offending path.
- Endpoints taking paths in the body (`/api/files/batch`, `/api/files/move`,
`/api/folders`, `/api/folders/move`) reject the **whole** request if any path
is out of scope, rather than silently doing part of the work.
- Operations that carry a whole subtree — deleting or moving a folder — need
that subtree to be in scope, not merely reachable: a token allowed only
`20_Projects/Pro/Sub` may browse down through `20_Projects/Pro`, but may not
delete or move it, and may not take the parent of one of its own `path_deny`
prefixes either.
- Listing endpoints (`/api/files`, `/api/search`, `/api/directory`,
`/api/projects`) filter results silently, so a restricted token cannot probe
for the existence of files it may not read. A directory that merely leads to
an allowed prefix stays browsable; its files do not become readable.
- **`/api/query` is refused to path-restricted tokens.** An arbitrary `SELECT`
cannot be filtered safely — an aggregate such as `group_concat(path)` would
leak content without ever returning a path column. Use `/api/files` with
frontmatter filters, which is properly scoped. Unrestricted tokens keep SQL.
- **`/api/webhooks` and `/api/tokens` require `admin`**, because both can bypass
path restrictions: a webhook with `include_body` streams file contents to an
arbitrary URL, and token creation can mint an unrestricted credential.
- `/api/files/batch`, `/api/query` and `/api/links/check` are reads that use
POST for their body, and need `read`, not `write`.
**Storage.** `/data/tokens.json` (the `sqlite-data` volume), outside the synced
vault, written atomically. Override with `TOKENS_CONFIG_PATH`. Only SHA-256
hashes are kept: a leak of that file yields nothing usable. Revoked tokens stay
in the file so their audit trail survives.
**From MCP**: `list_api_tokens()` and `revoke_api_token(id)` are exposed.
Creation deliberately is not — the MCP server proxies to the REST API with the
root `API_TOKEN`, so exposing creation would let any MCP caller mint an
unrestricted credential. Revocation is safe to expose because it only ever
removes access.
### Webhooks
Notify external systems (n8n, Zapier, your own service…) whenever vault files change. The embedded watcher detects `add` / `change` / `unlink` on `.md` files and POSTs a JSON payload to your URL. Webhooks are **created and managed only through the REST API** — the MCP server can list them but never create them.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/webhooks` | List all configured webhooks (secrets redacted) |
| `GET` | `/api/webhooks/{id}` | Get a single webhook |
| `POST` | `/api/webhooks` | Create a webhook |
| `PATCH` | `/api/webhooks/{id}` | Update a webhook (only supplied fields change) |
| `DELETE` | `/api/webhooks/{id}` | Delete a webhook |
| `POST` | `/api/webhooks/{id}/test` | Fire a test delivery and return the result |
**Webhook fields** (all optional except `url`):
| Field | Type | Description |
|-------|------|-------------|
| `url` | string | **Required.** Destination URL. Must be public `https://` by default (see SSRF note below). |
| `name` | string | Friendly label. |
| `folder` | string \| null | Directory filter — matches every file beneath it. Wildcards allowed in segments, e.g. `20_Projects/*/notes`. `null`/omitted = whole vault. |
| `frontmatter` | object \| null | Subset match on frontmatter, e.g. `{"type":"action"}`. Every key must be present and equal. `null`/omitted = any. |
| `frontmatter_not` | object \| null | Negated match: **skip** delivery if any of these key=value pairs match, e.g. `{"last_write_origin":"todoist"}`. A missing field never matches (so it passes). Useful to break webhook loops. `null`/omitted = no exclusion. |
| `events` | string[] | Subset of `add`, `change`, `unlink`. Default: all three. |
| `secret` | string | If set, each delivery is signed: `X-Obsidian-Signature: sha256=<hmac>` (HMAC-SHA256 of the JSON body). Never returned by the API. |
| `include_body` | boolean | Include the file body in the payload. Default `false` (metadata only). |
| `enabled` | boolean | Set `false` to pause delivery. Default `true`. |
**Delivery payload:**
```json
{
"event": "change",
"path": "20_Projects/alpha/notes/idea.md",
"frontmatter": { "type": "action", "status": "todo" },
"timestamp": "2026-06-03T10:00:00.000Z",
"webhook_id": "wh_…",
"body": "…"
}
```
`body` is included only when `include_body=true`. Headers: `X-Obsidian-Event: <event>` and, when a secret is set, `X-Obsidian-Signature`. Deliveries run off the watcher with a per-request timeout, bounded concurrency, and exponential-backoff retries (on network errors / 5xx / 429).
```bash
# Create a webhook for "action" notes under 20_Projects, signed
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.example.com/obsidian","folder":"20_Projects","frontmatter":{"type":"action"},"secret":"s3cr3t"}' \
https://obsidian-api.yourdomain.com/api/webhooks
# → {"id":"wh_…","url":"…","folder":"20_Projects","frontmatter":{"type":"action"},"events":["add","change","unlink"],"has_secret":true,...}
# Combined filter: type == action AND last_write_origin != todoist (loop-breaking)
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.example.com/obsidian","frontmatter":{"type":"action"},"frontmatter_not":{"last_write_origin":"todoist"}}' \
https://obsidian-api.yourdomain.com/api/webhooks
# List webhooks
curl -H "Authorization: Bearer $TOKEN" https://obsidian-api.yourdomain.com/api/webhooks
# Send a test delivery
curl -X POST -H "Authorization: Bearer $TOKEN" \
https://obsidian-api.yourdomain.com/api/webhooks/wh_…/test
# → {"ok":true,"status":200,"attempts":1}
```
**Configuration & persistence**
- The config is stored at `/data/webhooks.json` (the `sqlite-data` Docker volume), so it survives restarts and is **not** synced to your Obsidian devices. Override with `WEBHOOKS_CONFIG_PATH`.
- `WEBHOOK_ALLOW_PRIVATE` (default `false`): by default the server **blocks SSRF** — only public `https://` targets are allowed; loopback, private, link-local and cloud-metadata addresses (and `http://`) are rejected, redirects are not followed, and the target is re-checked before each delivery (anti DNS-rebinding). Set it to `true` only if your receiver lives on a private/internal address (e.g. a self-hosted n8n on the same network).
---
## MCP Server
Exposes the vault as MCP tools and resources for AI agents. Base URL: `https://mcp.DOMAIN`.
### Authentication
Two methods are supported, checked in this order:
**1. Authorization header — the env `API_TOKEN`, or any named token from the store
(recommended for Claude Code CLI / Codex CLI — simpler than OAuth for those clients)**
Prefer a named token over the shared `API_TOKEN`: one per client means you can
revoke the laptop's without touching n8n's, and `last_used_at` tells you whether
a key is still in use before you kill it.
> **MCP takes only unrestricted tokens.** This server calls the REST API with the
> root `API_TOKEN`, so a token's scopes and paths are *not* enforced on what you
> do through MCP. A path-restricted or read-only token is therefore **refused**
> with a `403` explaining why, rather than quietly running as root. Use such
> tokens against the REST API directly, where they are enforced. Scoping over
> MCP would require forwarding the caller's credential per request — separate
> work, not done here.
```json
{
"mcpServers": {
"obsidian": {
"url": "https://mcp.yourdomain.com",
"transport": "http",
"headers": {
"Authorization": "Bearer <API_TOKEN>"
}
}
}
}
```
> **Removed 2026-09-03 — token in the URL path.** `https://mcp.DOMAIN/<API_TOKEN>`
> is no longer accepted and returns `401`. A token in a URL is written to every
> proxy and access log it crosses, lands in browser history and `Referer`
> headers, and cannot be scrubbed from any of them afterwards; a header is not
> logged by default anywhere in that chain. Clients configured that way must
> move the token into `Authorization: Bearer`, as in method 1 above.
**2. OAuth 2.1 + PKCE (Zitadel)** — used automatically by OAuth-aware clients like claude.ai
when no static token is presented:
```json
{
"mcpServers": {
"obsidian": {
"url": "https://mcp.yourdomain.com",
"transport": "http"
}
}
}
```
On first connection such a client will:
1. Get a `401` with a `WWW-Authenticate` header pointing at `/.well-known/oauth-protected-resource`
2. Follow that to discover the Zitadel authorization server and start the Authorization Code + PKCE flow
3. Present you with a login/consent screen for your Zitadel account
4. Attach the resulting access token as `Authorization: Bearer <token>` on subsequent requests
For OAuth requests, access is granted only to users holding the `obsidian:access` project role
in Zitadel — the server checks this via `/oidc/v1/userinfo` on every request (see
[Security Notes](#security-notes)).
A second role, **`obsidian:admin`** (`OAUTH_ADMIN_ROLE`), gates the API-token
tools on top of that. `obsidian:access` lets an identity work in the vault;
`obsidian:admin` lets it hand out and revoke credentials.
### Resources
| URI | Description |
|-----|-------------|
| `obsidian://files` | List all markdown files in the vault |
| `obsidian://health` | Check vault health status |
### Tools
#### File Operations
| Tool | Description |
|------|-------------|
| `read_file(file_path)` | Read a markdown file; returns full content |
| `write_file(file_path, content)` | Write or create a file (full replace); response includes an `obsidian://open` deep link when `VAULT_NAME` is set, and a warning listing any broken `[[wikilinks]]` (with fuzzy suggestions) found in `content` |
| `append_to_file(file_path, content)` | Append content at end of file (creates it if missing); response includes an `obsidian://open` deep link when `VAULT_NAME` is set, and a warning listing any broken `[[wikilinks]]` (with fuzzy suggestions) found in the appended `content` — pre-existing broken links elsewhere in the file aren't reported |
| `patch_file(file_path, old_text, new_text, replace_all=False)` | Surgical text replacement — swaps `old_text` for `new_text` (first occurrence, or all with `replace_all=True`); errors if not found; response includes an `obsidian://open` deep link when `VAULT_NAME` is set, and a warning listing any broken `[[wikilinks]]` (with fuzzy suggestions) found in `new_text` — pre-existing broken links elsewhere in the file aren't reported |
| `move_file(file_path, destination)` | Move or rename a file within the vault; missing destination folders are created automatically; response includes an `obsidian://open` deep link to the new path when `VAULT_NAME` is set |
| `delete_file(file_path, hard=False)` | Delete a file — soft by default (moved to `.trash/`, recoverable); `hard=True` deletes permanently |
#### Frontmatter
| Tool | Description |
|------|-------------|
| `update_frontmatter(file_path, updates)` | Merge-update frontmatter fields; body untouched; set a value to `null` to delete a field; response includes an `obsidian://open` deep link when `VAULT_NAME` is set |
| `bulk_update_frontmatter(file_paths, updates)` | Apply the same frontmatter patch to multiple files (up to 100); each succeeded file's line includes an `obsidian://open` deep link when `VAULT_NAME` is set |
#### Directory & Search
| Tool | Description |
|------|-------------|
| `create_folders(folder_paths)` | Create one or more folders (up to 100), including missing parent folders — scaffolds a directory structure in one call |
| `delete_folders(folder_paths, hard=False)` | Delete one or more folders (up to 100), recursively — soft by default (moved to `.trash/`, recoverable); `hard=True` deletes permanently |
| `move_folders(moves)` | Move or rename one or more folders (up to 100); each entry is its own `{"from": ..., "to": ...}` dict — missing destination parent folders are created automatically |
| `list_directory(dir_path)` | List files and subdirectories; leave `dir_path` empty for vault root |
| `search_vault(query, mode, rerank, limit, fuzzy, since, before, path)` | Search vault — hybrid by default (`mode`: `auto`/`hybrid`/`semantic`/`bm25`/`grep`/`fuzzy`), optional cloud reranking, date and folder filters |
| `get_projects()` | List project folders under `20_Projects/Pro/` |
#### SQL & Index
| Tool | Description |
|------|-------------|
| `query_vault(sql)` | Run a SQL `SELECT` against the vault index (same `files`/`tasks` schema as the REST API) |
| `run_index(file_path, section)` | Execute SQL blocks embedded in a `_index.md` file; leave `section` empty to list available sections |
| `extract_tasks(file_path, isolate_tags=False)` | Extract markdown checklist items (`- [ ]` / `- [x]`) from a file as JSON: `{"checked": bool, "text": str, "tags": [...]}`; `isolate_tags=True` pulls every inline `[...]` bracket group (e.g. `[if:plage]`, `[require:passeport]`) out of `text` into the `tags` array as raw strings |
#### Sync
| Tool | Description |
|------|-------------|
| `sync_vault()` | Trigger vault sync with Obsidian Sync |
| `get_sync_status()` | Get current sync status, plus SQLite index / file watcher health (watcher liveness, last event, last error, indexed vs actual file count) |
#### Comments
Read and write [Document Comments](https://github.com/kylemcd/obsidian-document-comments) plugin threads directly in markdown — output is fully compatible with the plugin (created/edited here shows up and is editable in Obsidian, and vice versa). A thread is an anchor span `<!--c:ID-->text<!--/c:ID-->` wrapping the commented passage, plus a block `<!--co:ID by:author at:timestamp status:open|resolved quote:"..."` followed by one reply line per participant, closed by `-->`.
| Tool | Description |
|------|-------------|
| `extract_comments(file_path, file_paths, folder)` | Extract every comment thread from one or more files as JSON: `{"id", "status", "quote", "created_by", "created_at", "anchored_text", "replies": [{"author", "at", "text"}]}`. Provide exactly one of `file_path` / `file_paths` / `folder` (recursive) |
| `create_comment(file_path, quote, text, author="agent")` | Create a new thread anchored to the first occurrence of `quote` (must match the file's text exactly); returns the new thread's `comment_id` |
| `reply_to_comment(file_path, comment_id, text, author="agent")` | Append a reply to an existing thread without touching its status |
| `set_comment_status(file_path, comment_id, resolved)` | Resolve (`resolved=True`, i.e. "close") or reopen (`resolved=False`) a thread; anchored text and replies are untouched |
| `delete_comment(file_path, comment_id)` | Remove a thread entirely — anchor markers and thread block are deleted, the previously-anchored text is left in place as plain markdown |
#### Webhooks (read-only)
| Tool | Description |
|------|-------------|
| `list_webhooks()` | List active vault-change webhooks (secrets redacted). Webhooks are created/managed via the REST API, not from MCP. |
#### API tokens
| Tool | Description |
|------|-------------|
| `list_api_tokens()` | List the scoped REST API tokens — name, scopes, path restrictions, expiry, last use. Secrets are never returned. |
| `create_api_token(name, scopes, path_allow, path_deny, expires_at)` | Mint a token. The plaintext is in the response and nowhere else. |
| `revoke_api_token(token_id)` | Revoke a token by id, effective on the next request. |
These three require the **`obsidian:admin`** role (`OAUTH_ADMIN_ROLE`) on top of
`obsidian:access`. Using the vault and handing out credentials that reach it are
separate privileges: an identity can be given one without the other, and the
capability is withdrawn by removing the role in Zitadel — no redeploy, nothing
else affected.
The check runs in `AuthMiddleware`, which inspects the JSON-RPC body for a
`tools/call` naming one of these tools and returns `403` before the request ever
reaches the tool. It is deliberately not a contextvar set around the app call:
under streamable HTTP a tool can execute in a task created before the current
request, which would either lose the principal or — worse — inherit the previous
request's.
Callers presenting the static `API_TOKEN` pass this gate. That is not a
loophole so much as an acknowledgement: `API_TOKEN` is already root on the REST
API, so anyone holding it can `POST /api/tokens` directly. The role separation
is meaningful **between OAuth identities**, and becomes airtight once the static
Bearer path is removed from the MCP server too (the URL-path one already is).
---
## Troubleshooting
**Obsidian Headless not syncing**
- Check credentials in `.env` (`OBSIDIAN_EMAIL`, `OBSIDIAN_PASSWORD`, `VAULT_PASSWORD`)
- Verify `VAULT_NAME` matches exactly (case-sensitive)
- Check logs: `docker logs obsidian-headless`
**API returning 401**
- Confirm `API_TOKEN` is set in `.env` and matches your `Authorization: Bearer <token>` header
- `/health` is the only public endpoint — everything else requires the token
**API not responding**
- Check Traefik routing: `docker logs traefik`
- Verify DNS and `DOMAIN` env var
**SSL certificate issues**
- Wait ~5 minutes for the Let's Encrypt ACME challenge
- Ensure port 80 is open (required for ACME HTTP-01 validation)
- Verify `ACME_EMAIL` is correct
**SQL query errors**
- Only `SELECT` statements are allowed
- Tags are stored as JSON arrays: use `tags LIKE '%"tagname"%'`
- Frontmatter fields: use `json_extract(frontmatter, '$.field_name')`
**Index not updating / webhooks not firing on file changes**
- The live index and webhooks rely on a chokidar file watcher. On many Docker hosts (especially VPS bind mounts), inotify events don't propagate into the container, so changes go undetected.
- The compose file sets `CHOKIDAR_USEPOLLING=true` (with `CHOKIDAR_INTERVAL=1000` ms) on `obsidian-api` to poll instead. If you run the API outside this compose file, set those env vars yourself.
- Symptom check: create a `.md` file, then `POST /api/query` for it — if it never appears, the watcher isn't seeing changes (enable polling). The `/api/webhooks/{id}/test` endpoint bypasses the watcher, so it succeeding does **not** prove the watcher works.
---
### Search returns nothing, or only keyword-quality results
Check `GET /api/search/status` (or `get_sync_status` from MCP) first — it says
which half of the index is actually working.
| Symptom | Cause | Fix |
|---------|-------|-----|
| `chunks: 0` | The chunk backfill never ran | Restart `obsidian-api`; `reconcile()` chunks every note missing from the index on boot |
| `pending` stuck > 0, `embed_error` set | An API-key provider (`jina`/`openai`/`voyage`) has no key, or `EMBED_PROVIDER=local` hit a transient failure (e.g. the HuggingFace download) | A missing/wrong key needs the env var fixed **and the container recreated** — Docker never re-reads `.env` into a running container. A transient `local`-provider failure just needs `POST /api/search/reindex` |
| `pending` slowly decreasing | Backfill in progress (10–20 min for ~2k notes on CPU) | Wait; searches answer with BM25 meanwhile |
| `semantic_ready: false`, no error | `EMBED_PROVIDER=none`, or `@huggingface/transformers` missing from the install | Set a provider and make sure the package is in the container's `npm install` |
| `warnings: ["rerank: ..."]` | Reranker call failed | Results are still the hybrid ones; check `JINA_API_KEY` |
| Results look stale | Watcher died — see above | Restart the service |
## Security Notes
- Keep `.env` secure — never commit it to Git
- `API_TOKEN` is shared between the REST API and MCP server; all non-health endpoints are protected
- Obsidian Sync provides end-to-end encryption for vault data at rest
- Directory traversal is blocked server-side on all file endpoints
- **Webhooks**: created only via the authenticated REST API (never from MCP); the config lives outside the synced vault (`/data/webhooks.json`); secrets are stored server-side and redacted in all API/MCP responses. SSRF is blocked by default — only public `https://` targets are allowed, redirects are not followed, and the destination is re-validated before every delivery. Loosen this only via `WEBHOOK_ALLOW_PRIVATE=true` for trusted internal receivers.
- **MCP credential management**: `create_api_token` / `list_api_tokens` /
`revoke_api_token` require the `obsidian:admin` role, checked in the auth
middleware before the request reaches the tool. Note that the static
`API_TOKEN` also passes, since it is already root on the REST API and could
call `POST /api/tokens` directly — the separation bites between OAuth
identities, and would become absolute if the static Bearer path were retired
too (the URL-path variant already is).
- **MCP OAuth**: the MCP server (`mcp.DOMAIN`) also accepts OAuth 2.1 + PKCE via Zitadel
alongside the static `API_TOKEN`. For OAuth requests, every bearer token is validated
against Zitadel's `/oidc/v1/userinfo`, and access is denied (`403`) unless the token's
claims include the `obsidian:access` project role. This role check matters because
Zitadel doesn't support RFC 8707 resource indicators: a token issued for the shared
`Claude-web` client can carry an audience covering every MCP server in the `mcp-servers`
project, not just this one, so a valid signature alone isn't proof of authorization for
this specific server. The URL-path token variant was removed on 2026-09-03 (a
token in a URL is logged everywhere it travels and cannot be scrubbed back
out); the static Bearer token, compared in constant time, stays alongside OAuth.
---
## Hybrid search
Search is chunked, ranked, and hybrid: every note is split into ~2200-character
sections and each chunk is indexed twice — lexically (BM25 via FTS5) and
semantically (a vector embedding). A query runs against both and the two
rankings are fused.
### Why both halves
Neither retriever alone is enough on a real vault:
- BM25 finds `INC-4471`, `Nokia`, `WALB` — exact tokens a vector model blurs.
It cannot find a note that says *migration serveur* when you searched for
*changement d'hébergeur*.
- The vector half finds exactly that, and fails on rare identifiers it never
saw in training.
They are fused with **Reciprocal Rank Fusion** (`score = Σ 1/(60 + rank)`).
Rank-based fusion is the point: BM25 scores and cosine similarities live on
different scales, and RRF needs no per-corpus calibration to combine them.
### The pipeline
```
query
├─ FTS5 BM25 ─┐
│ (diacritic-folded, │
│ prefix variants) ├─ RRF fusion ─→ top 40 chunks ─→ [rerank] ─→ group by note
└─ vector search ─┘
(cosine over the
in-memory matrix)
```
Notes on each stage:
- **Chunking** splits on markdown headings, merges consecutive small sections,
and splits oversized ones at paragraph boundaries with a 300-character
overlap. Headings inside fenced code blocks are not treated as headings.
- **BM25** uses the `unicode61 remove_diacritics 2` tokenizer, so `reunion`
matches `réunion`. FTS5 has no French stemmer, so tokens of 4+ characters
also get a prefix variant — that is what makes `réunion` match `réunions`.
Column weights favour a hit in the note title or section heading.
- **Vector search** is a brute-force scan over an in-memory `Float32Array`.
At this vault's scale (~2k notes → ~5k chunks → ~8 MB) a full scan is a few
milliseconds; an ANN index would add a native dependency for no gain. Revisit
past ~100k chunks.
- **Embedding** happens in a background worker, in batches, after indexing.
Chunks are content-hashed, so an unchanged section keeps its vector when the
note around it is edited, and a moved or renamed note is never re-embedded.
### Embedding provider
`EMBED_PROVIDER=local` (the default) runs `multilingual-e5-small` on CPU
through transformers.js. Measured on 4 vCPU: **~100 ms per chunk** to index,
**~4 ms** to embed a query, and **~1 GB RSS** once the model is loaded. A
~2000-note vault backfills in roughly 10–20 minutes, once.
That gigabyte is the real cost of keeping everything local. If it is too much
for the box, `EMBED_PROVIDER=jina|openai|voyage` embeds through an API instead:
near-zero RAM, a few cents to index the whole vault at $0.02/M tokens — at the
price of sending note text to that provider. `EMBED_PROVIDER=none` disables the
semantic half; search still works, ranked, on BM25 alone.
Switching embedding model or provider invalidates the vectors. Clear them and
let the worker rebuild:
```bash
sqlite3 /data/vault-index.db "UPDATE chunks SET embedding = NULL, embed_model = NULL;"
curl -X POST -H "Authorization: Bearer $TOKEN" \
https://obsidian-api.yourdomain.com/api/search/reindex
```
### Reranking (optional)
A cross-encoder reranker reorders the top candidates and is the single largest
quality win available — but it is **off by default** and needs two things to
fire: an API key, and `rerank=true` on the request.
Local reranking is deliberately not supported. A 0.6B cross-encoder on a 2 vCPU
VPS costs *minutes* per query; the hosted call costs a few hundred milliseconds
and ~$0.0003 per search (Jina/Voyage, ~16k tokens at $0.02/M — roughly $1/month
at 100 searches a day).
The privacy trade-off is sharper than for embeddings, and worth being explicit
about: embedding a query sends ~5 words to the provider, while **reranking
sends the full text of every candidate passage**. `RERANK_EXCLUDE_PATHS` holds
back whole folders — their chunks are never put in the payload and keep their
pre-rerank position:
```bash
RERANK_EXCLUDE_PATHS=10_Context/perso,50_Archives/prive
```
A reranker outage degrades to the fused hybrid order and reports itself in
`warnings`; it never empties a result page.
### Tuning
| Variable | Default | Effect |
|----------|---------|--------|
| `SEARCH_CHUNK_MAX_CHARS` | `2200` | Chunk size ceiling (~550 tokens) |
| `SEARCH_CHUNK_OVERLAP_CHARS` | `300` | Overlap carried across a split |
| `SEARCH_CANDIDATE_LIMIT` | `40` | Chunks kept after fusion (and sent to the reranker) |
| `SEARCH_RRF_K` | `60` | RRF constant; lower favours top ranks more sharply |
| `EMBED_BATCH_SIZE` | `8` local / `64` remote | Chunks per embedding call — lower this if a remote provider's per-minute token limit gets tripped often |
| `EMBED_WORKER_BATCH` | `32` | Chunks pulled from the DB per worker pass |
| `EMBED_RETRY_MAX_ATTEMPTS` | `5` | Retries for a single batch on 429/5xx before giving up until the next scheduled pass |
| `EMBED_RETRY_BASE_MS` / `EMBED_RETRY_MAX_MS` | `5000` / `60000` | Backoff between retries (a provider's own `Retry-After` header wins when present) |
## Files Reference
### `obsidian-api.js`
Express REST API server. Handles file reads/writes, frontmatter parsing (js-yaml), search (hybrid, plus legacy grep/fuzzy modes), directory listing, wikilink resolution, and SQL queries via the vault indexer.
### `vault-indexer.js`
SQLite indexer (better-sqlite3). Bootstraps a full index on first start, then keeps it live via a chokidar file watcher. Indexes frontmatter, tags, and tasks from every `.md` file. Each `add`/`change`/`unlink` also fans out to the webhook dispatcher.
### `search-index.js`
Hybrid search: markdown chunking, the FTS5/BM25 index, the vector index and its
brute-force cosine scan, RRF fusion, and the background embedding worker. Shares
the indexer's SQLite handle rather than opening its own.
### `embeddings.js`
Embedding providers behind one interface — `local` (transformers.js on CPU),
`jina`, `openai`, `voyage`, `none`. Always returns L2-normalised vectors, and
never throws fatally: an unavailable provider degrades search to BM25.
### `rerank.js`
Optional cloud reranking of search candidates (Jina, Cohere, Voyage), with the
`RERANK_EXCLUDE_PATHS` guard and a failure path that preserves the hybrid order.
### `webhooks.js`
Webhook configuration, matching, and delivery. Persists webhooks to `/data/webhooks.json` (atomic writes), filters changes by folder glob and frontmatter subset, and POSTs signed payloads with bounded concurrency, timeouts, retries, and SSRF protection. Created/managed via the REST API; listed read-only via MCP.
### `tokens.js`
Scoped API tokens: creation, hashing, lookup and the allow/deny path logic.
Persists to `/data/tokens.json` (atomic writes), stores SHA-256 only, and never
returns a secret after creation.
### `obsidian_mcp.py`
FastMCP server with streamable HTTP transport. Proxies all operations to the REST API. Includes
`AuthMiddleware`, which accepts either the static token (Bearer header, constant-time compare) or
an OAuth 2.1 access token validated against Zitadel, plus the `/.well-known/oauth-protected-resource`
metadata endpoint required by OAuth-aware MCP clients.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessUnresponsive