obsidian-headless-mcp
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 |
|
| Server health check (no auth required) |
curl https://obsidian-api.yourdomain.com/health
# → {"status":"ok","vault":"/vault"}Files — single file
Method | Path | Description |
|
| Read a file — returns |
|
| Write or create a file (full content replace) |
|
| Merge-update frontmatter fields (body untouched) |
|
| Replace body only (frontmatter untouched) |
|
| Surgical text replace — swap |
|
| Append content at end of file |
|
| Move file to a new path |
|
| Delete a file — soft by default (moved to |
|
| List broken wikilinks in a file (optionally with fuzzy suggestions) |
|
| 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.mdUpdate 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.mdAppend 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/appendSurgical 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:
400—old_textmissing/empty, ornew_textis not a string404— file does not exist422—old_textnot 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/moveDelete 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 |
|
| List all |
|
| Read up to 100 files in one request |
|
| Apply same frontmatter patch to up to 100 files |
|
| Move multiple files to a destination folder |
List files with filters
Query parameters (all optional):
path— substring match on file pathsince=YYYY-MM-DD— only files created on or after this datebefore=YYYY-MM-DD— only files created on or before this dateany 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/batchBulk 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/batchBulk 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/moveFolders
Method | Path | Description |
|
| Create one or more folders (batch), including missing parent folders |
|
| Delete one or more folders (batch), recursively |
|
| 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 |
|
| List vault root (files and subdirectories) |
|
| 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}Search
Method | Path | Description |
|
| Search vault content (hybrid by default) |
|
| Chunk counts, embedding progress, provider health |
|
| Kick the background embedding worker |
Query parameters:
q(required) — search termmode—auto(default) ·hybrid·semantic·bm25·grep·fuzzyrerank=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_Projectssince=YYYY-MM-DD/before=YYYY-MM-DD— filter by note datefuzzy=true— legacy alias formode=fuzzy
Which mode to use:
Mode | What it does | Reach for it when |
| Hybrid if the semantic index is ready, else | Almost always |
| BM25 + vector, fused with RRF | Best general recall |
| Vector only | The query's wording won't appear in the notes |
| Ranked full-text only | Names, acronyms, jargon |
| Literal substring, unranked | An exact string: an ID, a URL, a snippet |
| 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 |
|
| Run a SQL |
The vault index has two tables:
files
Column | Type | Description |
| TEXT | Relative path from vault root |
| TEXT | Frontmatter |
| TEXT | Frontmatter |
| TEXT | Frontmatter |
| TEXT | JSON array of tags (frontmatter + inline |
| TEXT | Full frontmatter as JSON object |
tasks
Column | Type | Description |
| TEXT | Parent file path |
| TEXT | Task text (without the checkbox) |
| INTEGER |
|
| TEXT | Due date YYYY-MM-DD (from |
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/queryUseful 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 filesProjects
Method | Path | Description |
|
| List all subdirectories of |
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 |
|
| Read |
Returns the contents of agent.md, which can hold instructions or context for AI agents working with the vault.
Sync
Method | Path | Description |
|
| Trigger a vault sync with Obsidian Sync |
|
| 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: trueor a persistently oldlast_event.atwhile files keep changing on disk is a strong signal the watcher died and needs a restart.in_sync: falsemeans the indexed file count doesn't match the vault's actual.mdfile count — a full reindex (restart the service) will resync it.search.semantic_ready: falsewith a non-zerosearch.pendingmeans the embedding backfill is still running; searches answer with BM25 in the meantime. A non-nullsearch.embed_errormeans 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 |
|
| List tokens (metadata only — secrets are never returned) |
|
| Get one token |
|
| Create a token — the plaintext is in this response and nowhere else |
|
| Revoke, effective on the next request |
|
| 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 |
| string | Required. What this token is for — it is what you will read when deciding whether to revoke it. |
| string[] |
|
| string[] | Vault-relative prefixes this token may reach. Empty = whole vault. |
| string[] | Prefixes it may never reach. Deny always wins over allow. |
|
| 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_a1b2c3d4e5f6How restrictions are enforced
Endpoints addressing one path (
/api/file/{path}and its/append,/move,/body,/patch,/linksvariants,/api/directory/{path}) return403with 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/Submay browse down through20_Projects/Pro, but may not delete or move it, and may not take the parent of one of its ownpath_denyprefixes 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/queryis refused to path-restricted tokens. An arbitrarySELECTcannot be filtered safely — an aggregate such asgroup_concat(path)would leak content without ever returning a path column. Use/api/fileswith frontmatter filters, which is properly scoped. Unrestricted tokens keep SQL./api/webhooksand/api/tokensrequireadmin, because both can bypass path restrictions: a webhook withinclude_bodystreams file contents to an arbitrary URL, and token creation can mint an unrestricted credential./api/files/batch,/api/queryand/api/links/checkare reads that use POST for their body, and needread, notwrite.
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 |
|
| List all configured webhooks (secrets redacted) |
|
| Get a single webhook |
|
| Create a webhook |
|
| Update a webhook (only supplied fields change) |
|
| Delete a webhook |
|
| Fire a test delivery and return the result |
Webhook fields (all optional except url):
Field | Type | Description |
| string | Required. Destination URL. Must be public |
| string | Friendly label. |
| string | null | Directory filter — matches every file beneath it. Wildcards allowed in segments, e.g. |
| object | null | Subset match on frontmatter, e.g. |
| object | null | Negated match: skip delivery if any of these key=value pairs match, e.g. |
| string[] | Subset of |
| string | If set, each delivery is signed: |
| boolean | Include the file body in the payload. Default |
| boolean | Set |
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(thesqlite-dataDocker volume), so it survives restarts and is not synced to your Obsidian devices. Override withWEBHOOKS_CONFIG_PATH.WEBHOOK_ALLOW_PRIVATE(defaultfalse): by default the server blocks SSRF — only publichttps://targets are allowed; loopback, private, link-local and cloud-metadata addresses (andhttp://) are rejected, redirects are not followed, and the target is re-checked before each delivery (anti DNS-rebinding). Set it totrueonly 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 a403explaining 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 returns401. A token in a URL is written to every proxy and access log it crosses, lands in browser history andRefererheaders, 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 intoAuthorization: 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:
Get a
401with aWWW-Authenticateheader pointing at/.well-known/oauth-protected-resourceFollow that to discover the Zitadel authorization server and start the Authorization Code + PKCE flow
Present you with a login/consent screen for your Zitadel account
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 |
| List all markdown files in the vault |
| Check vault health status |
Tools
File Operations
Tool | Description |
| Read a markdown file; returns full content |
| Write or create a file (full replace); response includes an |
| Append content at end of file (creates it if missing); response includes an |
| Surgical text replacement — swaps |
| Move or rename a file within the vault; missing destination folders are created automatically; response includes an |
| Delete a file — soft by default (moved to |
Frontmatter
Tool | Description |
| Merge-update frontmatter fields; body untouched; set a value to |
| Apply the same frontmatter patch to multiple files (up to 100); each succeeded file's line includes an |
Directory & Search
Tool | Description |
| Create one or more folders (up to 100), including missing parent folders — scaffolds a directory structure in one call |
| Delete one or more folders (up to 100), recursively — soft by default (moved to |
| Move or rename one or more folders (up to 100); each entry is its own |
| List files and subdirectories; leave |
| Search vault — hybrid by default ( |
| List project folders under |
SQL & Index
Tool | Description |
| Run a SQL |
| Execute SQL blocks embedded in a |
| Extract markdown checklist items ( |
Sync
Tool | Description |
| Trigger vault sync with Obsidian Sync |
| 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 every comment thread from one or more files as JSON: |
| Create a new thread anchored to the first occurrence of |
| Append a reply to an existing thread without touching its status |
| Resolve ( |
| 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 active vault-change webhooks (secrets redacted). Webhooks are created/managed via the REST API, not from MCP. |
API tokens
Tool | Description |
| List the scoped REST API tokens — name, scopes, path restrictions, expiry, last use. Secrets are never returned. |
| Mint a token. The plaintext is in the response and nowhere else. |
| 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_NAMEmatches exactly (case-sensitive)Check logs:
docker logs obsidian-headless
API returning 401
Confirm
API_TOKENis set in.envand matches yourAuthorization: Bearer <token>header/healthis the only public endpoint — everything else requires the token
API not responding
Check Traefik routing:
docker logs traefikVerify DNS and
DOMAINenv 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_EMAILis correct
SQL query errors
Only
SELECTstatements are allowedTags 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(withCHOKIDAR_INTERVAL=1000ms) onobsidian-apito poll instead. If you run the API outside this compose file, set those env vars yourself.Symptom check: create a
.mdfile, thenPOST /api/queryfor it — if it never appears, the watcher isn't seeing changes (enable polling). The/api/webhooks/{id}/testendpoint 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 |
| The chunk backfill never ran | Restart |
| An API-key provider ( | A missing/wrong key needs the env var fixed and the container recreated — Docker never re-reads |
| Backfill in progress (10–20 min for ~2k notes on CPU) | Wait; searches answer with BM25 meanwhile |
|
| Set a provider and make sure the package is in the container's |
| Reranker call failed | Results are still the hybrid ones; check |
Results look stale | Watcher died — see above | Restart the service |
Security Notes
Keep
.envsecure — never commit it to GitAPI_TOKENis shared between the REST API and MCP server; all non-health endpoints are protectedObsidian 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 publichttps://targets are allowed, redirects are not followed, and the destination is re-validated before every delivery. Loosen this only viaWEBHOOK_ALLOW_PRIVATE=truefor trusted internal receivers.MCP credential management:
create_api_token/list_api_tokens/revoke_api_tokenrequire theobsidian:adminrole, checked in the auth middleware before the request reaches the tool. Note that the staticAPI_TOKENalso passes, since it is already root on the REST API and could callPOST /api/tokensdirectly — 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 staticAPI_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 theobsidian:accessproject role. This role check matters because Zitadel doesn't support RFC 8707 resource indicators: a token issued for the sharedClaude-webclient can carry an audience covering every MCP server in themcp-serversproject, 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 2tokenizer, soreunionmatchesréunion. FTS5 has no French stemmer, so tokens of 4+ characters also get a prefix variant — that is what makesréunionmatchré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/reindexReranking (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/priveA reranker outage degrades to the fused hybrid order and reports itself in
warnings; it never empties a result page.
Tuning
Variable | Default | Effect |
|
| Chunk size ceiling (~550 tokens) |
|
| Overlap carried across a split |
|
| Chunks kept after fusion (and sent to the reranker) |
|
| RRF constant; lower favours top ranks more sharply |
|
| Chunks per embedding call — lower this if a remote provider's per-minute token limit gets tripped often |
|
| Chunks pulled from the DB per worker pass |
|
| Retries for a single batch on 429/5xx before giving up until the next scheduled pass |
|
| Backoff between retries (a provider's own |
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