obsidian-headless-mcp

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.

Category
访问服务器

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:

  • 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":["..."]}]}

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

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 term
  • fuzzy=true — fuzzy title matching with scoring (default: exact keyword match via grep)
  • since=YYYY-MM-DD — filter by creation date
  • before=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 (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 — 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_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.

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.

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

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选