obsidian-headless-mcp
Provides tools for remote access and management of an Obsidian vault, including reading, writing, searching, and batch operations on markdown files, as well as querying vault metadata.
Enables running SQL SELECT queries against the vault's SQLite index to search frontmatter, tags, and tasks across all markdown files.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@obsidian-headless-mcpfind all notes with tag #project-x"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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)Related MCP server: Obsidian MCP
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 auth3. 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 |
|
| 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 (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.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":["..."]}]}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/moveDirectory
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 |
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 |
|
| 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/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 |
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 — 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 |
| 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) |
| Append content at end of file |
| Surgical text replacement — swaps |
| Move or rename a file within the vault; missing destination folders are created automatically |
| 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) |
Directory & Search
Tool | Description |
| List files and subdirectories; leave |
| Search vault — keyword (default) or fuzzy with date filters |
| List project folders under |
SQL & Index
Tool | Description |
| Run a SQL |
| Execute SQL blocks embedded in a |
Sync
Tool | Description |
| Trigger vault sync with Obsidian Sync |
| Get current sync status |
Webhooks (read-only)
Tool | Description |
| 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 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.
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.
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
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/seb7152/obsidian-headless-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server