obsidian-headless-mcp
MCP server that provides tools and resources for AI models to interact with an Obsidian vault, enabling file operations, search, and management via the Model Context Protocol.
README
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. 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 # Shared token for REST API + MCP auth
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 <API_TOKEN>
Exception: GET /health is public.
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 (optionally with fuzzy suggestions) |
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:
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/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":["..."]}]}
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 pathsince=YYYY-MM-DD— only files created on or after this datebefore=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
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}
Search
| Method | Path | Description |
|---|---|---|
GET |
/api/search |
Search vault content |
Query parameters:
q(required) — search termfuzzy=true— fuzzy title matching with scoring (default: exact keyword match via grep)since=YYYY-MM-DD— filter by creation datebefore=YYYY-MM-DD— filter by creation date
# Keyword search
curl -H "Authorization: Bearer $TOKEN" \
"https://obsidian-api.yourdomain.com/api/search?q=meeting+notes&since=2025-01-01"
# Fuzzy search
curl -H "Authorization: Bearer $TOKEN" \
"https://obsidian-api.yourdomain.com/api/search?q=meting+nots&fuzzy=true"
# → {"query":"...","results":[{"file":"...","title":"...","matches":["..."],"date":"2025-03-10","score":0.82}],"count":3,"fuzzy":true}
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/ |
curl -H "Authorization: Bearer $TOKEN" \
https://obsidian-api.yourdomain.com/api/projects
# → {"projects":[{"name":"ProjectA","path":"20_Projects/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 |
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(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 — use whichever your client supports:
1. Authorization header (recommended)
{
"mcpServers": {
"obsidian": {
"url": "https://mcp.yourdomain.com",
"transport": "http",
"headers": {
"Authorization": "Bearer <API_TOKEN>"
}
}
}
}
2. Token in URL path (legacy)
{
"mcpServers": {
"obsidian": {
"url": "https://mcp.yourdomain.com/<API_TOKEN>",
"transport": "http"
}
}
}
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) |
append_to_file(file_path, content) |
Append content at end of file |
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 |
move_file(file_path, destination) |
Move or rename a file within the vault; missing destination folders are created automatically |
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 |
bulk_update_frontmatter(file_paths, updates) |
Apply the same frontmatter patch to multiple files (up to 100) |
Directory & Search
| Tool | Description |
|---|---|
list_directory(dir_path) |
List files and subdirectories; leave dir_path empty for vault root |
search_vault(query, fuzzy, since, before) |
Search vault — keyword (default) or fuzzy with date filters |
get_projects() |
List project folders under 20_Projects/ |
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 |
Sync
| Tool | Description |
|---|---|
sync_vault() |
Trigger vault sync with Obsidian Sync |
get_sync_status() |
Get current sync status |
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. |
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 traefik - Verify 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 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(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.
Security Notes
- Keep
.envsecure — never commit it to Git API_TOKENis 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 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.
Files Reference
obsidian-api.js
Express REST API server. Handles file reads/writes, frontmatter parsing (js-yaml), search (grep + fuzzy), 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.
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.
obsidian_mcp.py
FastMCP server with streamable HTTP transport. Proxies all operations to the REST API. Includes TokenAuthMiddleware supporting both URL-path and Bearer-header authentication.
License
MIT
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。