Skip to main content
Glama
seb7152

obsidian-headless-mcp

by seb7152

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).

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.

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

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.

Health

Method

Path

Description

GET

/health

Server health check (no auth required)

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

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

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

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

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.

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:

  • 400old_text missing/empty, or new_text is not a string

  • 404 — file does not exist

  • 422old_text not found in the file (nothing is changed; the edit never applies silently)

Move a file

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

# 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

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):

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

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

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

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

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.

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.

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.

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

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}

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

  • modeauto (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

# 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.

# 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:

-- 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/

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.

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.

# 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:

{
  "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).

# 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.

{
  "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:

{
  "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).

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

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 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.


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:

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:

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