Obsidian MCP Server
Remote Obsidian vault management over SSE: notes, directories, frontmatter, tags, search and link operations, deployable with Docker.
Install
The installation steps are in the documentation below. Documentation
Documentation
From the project's README on GitHub, refreshed weekly. Links open on GitHub.
Contents
- Obsidian MCP Server
- Features
- Quick Start
- AI Assistant Configuration
- Available Tools
- Architecture
- Project Structure
- Environment Variables
- Learn log
- Semantic search
- API Endpoints
- Authentication
- Deployment with NGINX Proxy Manager
- Private access with Tailscale
- Troubleshooting
- Development
- Contributing
- License
- Related Projects
Obsidian MCP Server
A Model Context Protocol (MCP) server that enables AI assistants like Claude to interact with your Obsidian vault. Access your notes, create content, manage tags, and search your knowledge base through natural conversation.
Features
Note Management
- CRUD Operations: Create, read, update, and delete notes (with safety confirmation)
- Write Modes: Overwrite, append, or prepend content
- Batch Reading: Read multiple notes in a single request
- File Info: Get metadata without reading content (efficient for large vaults)
- Move/Duplicate: Reorganize your vault structure
- Section Operations: Read, append, or replace specific sections by heading
Frontmatter & Tags
- Frontmatter Parsing: Get/set YAML frontmatter as structured JSON (powered by gray-matter)
- Tag Management: Add/remove tags (frontmatter or inline)
- Tag Auditing: Find notes missing required tags
- Tag Search: List all tags with usage counts
Search & Links
- Full-Text Search: Search content and filenames with context
- Backlinks: Find all notes linking to a specific note
- Broken Links: Detect wiki-links that don't resolve
- Find & Replace: Bulk text replacement with regex support
Semantic Search (optional)
- Search by meaning: hybrid vector + full-text retrieval over heading-aware chunks, backed by Postgres + pgvector and local embeddings from Ollama
- Similar notes: nearest notes to a given note, by whole-note embedding
- Incremental indexer: a second process from the same image that polls the vault and re-embeds only what changed; works over NFS/SMB where inotify does not
- Nothing leaves your network: embeddings are computed locally; the feature is off entirely unless
DATABASE_URLis set
Directory Operations
- Create/Delete/Rename: Full directory management
- List Contents: Browse vault structure
Performance
- Token Optimization: Optional compact response mode (40-60% smaller responses)
- Efficient Scanning: Get file info without reading content
- SSE Transport: Remote access without local installation
Quick Start
How clients connect: the server speaks MCP over HTTP + SSE only (there is no stdio mode). You start it (npx, Docker or from source), then point a client at http://HOST:PORT/sse. Clients that only support stdio servers connect through mcp-remote, as shown below. It listens on all network interfaces on PORT, so set an API_KEY (see Authentication) or keep it on loopback (for example -p 127.0.0.1:3001:3000 in Docker) unless you want the whole network to have read/write access to your vault.
npx (Quickest)
Requires Node.js 20.19 or newer. This starts the server in the foreground (default port 3000):
VAULT_PATH=/path/to/your/vault npx @smith-and-web/obsidian-mcp-server
With options:
VAULT_PATH=/path/to/vault PORT=3001 COMPACT_RESPONSES=true npx @smith-and-web/obsidian-mcp-server
Docker (Recommended for Production)
Using the pre-built image from GitHub Container Registry:
docker run -d \
--name obsidian-mcp \
-v /path/to/your/vault:/vault:rw \
-p 127.0.0.1:3001:3000 \
-e VAULT_PATH=/vault \
ghcr.io/smith-and-web/obsidian-mcp-server:latest
latest follows the newest release; pin a version tag (for example :1.6.0) for production. Drop 127.0.0.1: only if you set API_KEY and want other machines to connect.
Or with Docker Compose:
- Create a
docker-compose.yml:services: obsidian-mcp: image: ghcr.io/smith-and-web/obsidian-mcp-server:latest container_name: obsidian-mcp restart: unless-stopped volumes: - /path/to/your/vault:/vault:rw ports: - "127.0.0.1:3001:3000" environment: - VAULT_PATH=/vault - Start the server
docker compose up -d - Verify it's running
curl http://localhost:3001/health
Local Development
- Install dependencies
npm install - Set environment variables
export VAULT_PATH=/path/to/your/vault export PORT=3000 - Start the server
npm start # Or with auto-reload: npm run dev
AI Assistant Configuration
Claude Desktop
Add to your Claude Desktop config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Option 1: Local server (via mcp-remote)
{
"mcpServers": {
"obsidian": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3001/sse"]
}
}
}
Option 2: Remote server with HTTPS
{
"mcpServers": {
"obsidian": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://your-domain.com/sse"]
}
}
}
The server must already be running (see Quick Start). Claude Desktop launches mcp-remote, which bridges its stdio to the server's SSE endpoint; Claude Desktop cannot launch this server directly, because the server does not speak stdio.
Cursor
Add to your Cursor MCP settings (Settings → MCP):
{
"mcpServers": {
"obsidian": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3001/sse"]
}
}
}
Other MCP Clients
Claude Code supports SSE servers directly:
claude mcp add --transport sse obsidian http://localhost:3001/sse
# with an API key:
claude mcp add --transport sse obsidian https://your-host/sse --header "Authorization: Bearer your-secret-key"
Any other MCP-compatible client can connect using mcp-remote:
npx mcp-remote http://localhost:3001/sse
Or connect directly to the SSE endpoint at http://localhost:3001/sse if the client supports SSE servers natively.
Available Tools
Note Operations
| Tool | Description |
|---|---|
read-note | Read note contents (supports frontmatterOnly for efficiency) |
read-multiple-notes | Batch read multiple notes |
create-note | Create a new note |
edit-note | Replace note contents |
write-note | Write with modes: overwrite, append, or prepend |
delete-note | Delete a note (requires confirmation) |
move-note | Move/rename a note |
duplicate-note | Copy a note to a new location |
get-notes-info | Get file metadata without reading content |
Directory Operations
| Tool | Description |
|---|---|
list-vault | List files and directories |
create-directory | Create a new directory |
delete-directory | Delete a directory (with recursive option) |
rename-directory | Rename/move a directory |
Frontmatter & Tags
| Tool | Description |
|---|---|
get-frontmatter | Get YAML frontmatter as JSON |
update-frontmatter | Update frontmatter fields in place (untouched fields keep their exact text; invalid YAML is refused) |
add-tags | Add tags to frontmatter or inline |
remove-tags | Remove tags from note |
list-tags | List all tags with counts |
find-notes-by-tag | Find notes with a specific tag |
search-missing-tag | Find notes missing a tag |
audit-tags | Audit folder for required tags |
Search & Links
| Tool | Description |
|---|---|
search-vault | Full-text search with context |
get-backlinks | Find notes linking to a note |
find-broken-links | Find unresolved wiki-links |
find-replace | Bulk find and replace |
Semantic Search (only when DATABASE_URL is set)
| Tool | Description | ||
|---|---|---|---|
semantic-search | Hybrid vector + full-text search; filters by directory, tags, frontmatter type and status; `mode: hybrid | vector | text` |
similar-notes | Notes nearest to a note by centroid embedding | ||
index-status | Model, counts, last indexer run and lag | ||
reindex-note | Re-index one note immediately | ||
search-feedback | Record a search rating and used or expected paths; available only with the learn log enabled |
See Semantic search for setup.
Section Operations
| Tool | Description |
|---|---|
read-section | Read content under a heading |
append-to-section | Append to a section |
replace-section | Replace section content |
append-to-file | Append to end of file |
insert-at-marker | Insert at a text marker |
list-headings | List all headings in a note |
Architecture
┌─────────────────┐ HTTPS/SSE ┌──────────────────┐
│ Claude Desktop │ ◄────────────────► │ MCP Server │
└─────────────────┘ │ (Express.js) │
└────────┬─────────┘
│
┌────────▼─────────┐
│ VaultManager │
│ (File System) │
└────────┬─────────┘
│
┌────────▼─────────┐
│ Obsidian Vault │
│ (Markdown) │
└──────────────────┘
Project Structure
obsidian-mcp-server/
├── src/
│ ├── index.ts # Express server entry point
│ ├── vault/
│ │ ├── VaultManager.ts # Core vault operations
│ │ └── frontmatter.ts # YAML parsing utilities
│ ├── tools/
│ │ ├── definitions.ts # MCP tool schemas
│ │ ├── handlers.ts # Tool execution logic
│ │ └── index.ts # Tool exports
│ ├── server/
│ │ ├── mcp.ts # MCP protocol handlers
│ │ └── middleware.ts # Express middleware
│ ├── index/ # Optional semantic search
│ │ ├── config.ts # DATABASE_URL / OLLAMA_URL / EMBED_MODEL
│ │ ├── exclusions.ts # What the indexer never reads
│ │ ├── chunker.ts # Heading-aware chunking
│ │ ├── embedder.ts # Ollama client (+ a fake for tests)
│ │ ├── ranking.ts # Fusion, weights, per-note cap and env validation
│ │ ├── store.ts # pgvector schema, upserts, hybrid search
│ │ ├── scanner.ts # Vault walk and change detection
│ │ └── SearchService.ts # What the MCP tools call
│ ├── learn/ # Learn log, redaction, configuration and reports
│ ├── learn-report.ts # Read-only learn-report CLI
│ ├── eval/ # Golden validation, metrics and fixed ranking grid
│ ├── eval.ts # Read-only brain-eval CLI entry point
│ ├── indexer.ts # Indexer entry point (second container)
│ └── types/
│ └── index.ts # TypeScript type definitions
├── sql/001_schema.sql # Index schema (dimension substituted at start)
├── tests/ # Vitest unit tests
├── Dockerfile
├── docker-compose.yml
├── package.json
└── README.md
Environment Variables
| Variable | Default | Description |
|---|---|---|
PORT | 3000 | Server port |
VAULT_PATH | /vault | Path to Obsidian vault |
COMPACT_RESPONSES | false | Enable minified response keys for 40-60% smaller responses |
API_KEY | (none) | API key for authentication. When set, requires a Bearer token (compared in constant time) |
REQUIRE_TAILSCALE_LOGIN | (none) | Optional. Require the Tailscale-User-Login header (set by tailscale serve) to equal this login. Only trustworthy when the server is reachable solely through tailscale serve on loopback |
MCP_INSTRUCTIONS_FILE | (none) | Optional. Path to a text/Markdown file sent to clients as the MCP instructions in the initialize result: what this vault is and when to use it. Most clients add it to the model's context. A missing file is a startup error. Keep it short (some clients truncate around 2 KB) |
MCP_INSTRUCTIONS | (none) | Same, inline. Ignored when MCP_INSTRUCTIONS_FILE is set |
DATABASE_URL | (none) | Postgres + pgvector connection string. Enables semantic search; unset = feature off |
OLLAMA_URL | http://localhost:11434 | Ollama server for embeddings |
EMBED_MODEL | nomic-embed-text | Ollama embedding model; dimension is discovered from it |
LEARN_LOG | false | Enable the optional learn log; invalid configuration disables logging |
LEARN_REDACT_PATTERNS_FILE | unset | Required readable regex file when logging is enabled |
LEARN_RETENTION_DAYS | 30 | Days before query text, embeddings and feedback notes are cleared; integer 1–3650 |
INDEX_COLLECTION | default | Name of this vault's collection in the shared database |
SEARCH_FUSION | rrf | Fusion: rrf (weighted reciprocal rank) or dbsf (distribution-based score fusion) |
SEARCH_RRF_K | 60 | RRF rank constant, integer 1–1000; validated but unused under DBSF |
SEARCH_W_VECTOR | 1 | Vector-leg weight, finite number 0–10 |
SEARCH_W_TEXT | 1 | Full-text-leg weight, finite number 0–10; both weights cannot be zero |
SEARCH_PER_NOTE_CAP | 0 | Maximum chunks per note, integer 0–8; 0 means no cap |
INDEX_INTERVAL_SECONDS | 60 | Indexer only: seconds between vault scans |
INDEX_VERBOSE | false | Indexer only: log every file indexed or removed |
EMBED_BATCH_SIZE | 16 | Texts per Ollama request |
EMBED_PROVIDER | ollama | ollama, or fake (deterministic hash embeddings for tests and smoke runs; not for real search) |
EMBED_DIM | (auto) | Embedding dimension for the fake provider (default 64); ignored with Ollama, where it is discovered from the model |
Learn log
The optional measurement layer requires semantic search and is off by default. It records the three search tools (semantic-search, similar-notes, search-vault), successful reads (read-note, read-multiple-notes, read-section), explicit feedback and SSE session connect/close with client names. Search ranking is unchanged; search never reads these logs.
Set LEARN_LOG=true and LEARN_REDACT_PATTERNS_FILE to a readable UTF-8 file. Blank lines and comments are ignored, whitespace is trimmed, and each other line is a JavaScript regex source compiled case-insensitively. An example file is:
# comment
\bexample-term\b
Eight built-in patterns also detect private-key headers, access-key shapes, JWTs, hosted Git tokens, secret assignments, bearer credentials, URL token parameters and 64-character hex strings. Sensitive queries (including similar-note paths) and their embeddings are cleared before storage. Sensitive string parameters become [redacted]; sensitive feedback notes are cleared, sensitive feedback paths are dropped, and sensitive read paths are never logged. Site-specific patterns belong in the external file. An invalid configuration disables logging, never search, and is reported at startup.
Logged searches return an additive requestId. Pass it to a read's optional requestId argument, then call search-feedback with requestId, rating (positive, negative, neutral) and usedPaths. expectedPaths and a short note are optional. source defaults to agent; use user only for explicit human feedback. Unknown ids return { recorded: false, reason: 'unknown requestId' }. Reads with unknown ids are recorded without a link.
Search, read and session writes are fire and forget: they never delay tool responses and failures are logged with learn-log:. Feedback is awaited. The indexer loop applies retention at most daily, excluding --once. After LEARN_RETENTION_DAYS (default 30), query text, query embeddings and feedback notes are cleared. Paths, ranks, timestamps, checked parameters, client names and feedback paths remain. sql/002_learning.sql creates the four learn tables even when logging is off; the embedding column is recreated after a dimension reset. Keep learn-table data out of database dumps and exclude raw database files from backups when query retention is required.
Run the read-only report against an existing database:
node dist/learn-report.js --days 30 --out /existing-directory/learn-report.json
--days defaults to 30 (range 1–365); the output directory must already exist. It uses a read-only connection, ignores ranking and logging environment settings, and outputs counts and rates without query text. Stdout lists dotted keys in report order, followed by VOLUME GATE: pass|fail.
The report covers the selected collection and time window. Sessions include counts by client and the median closed duration in minutes. Searches include counts by tool and redacted share. Linked semantic reads use the minimum rank of the read path: usedAt1, usedAt3, usedAt8 are shares of linked reads, and missRate is the share absent from results. linkedSearchRate is the share of semantic searches with a linked read, and linkedSemanticPerDay is their daily count. Reformulation and fallback rates count session-linked semantic searches followed within 120 seconds by another semantic search or text search. Feedback coverage is the share of semantic searches with feedback; source counts and negative rates include all feedback. Zero-denominator rates and empty medians are null; numbers are rounded to four decimals. The volume gate passes at a rounded 30 linked semantic searches per day. Feedback from agents measures compliance; user corrections provide independent labels.
Semantic search
Off by default. Setting DATABASE_URL turns it on in both the server (four extra tools) and the indexer (a second process that keeps the index current). Everything runs locally: Postgres with the pgvector extension, and Ollama for embeddings.
How it works
- The vault stays the source of truth. The index is a derivative you can drop and rebuild (
--reindex). - Chunking is heading-aware. Notes split on H1–H3; sections longer than ~800 tokens split further with overlap; each chunk carries its breadcrumb (
Title › H2 › H3). Chunk 0 is the title plus the frontmattersummary, so a one-line summary is the strongest signal for "which note answers this". - Retrieval is hybrid. Cosine similarity over an HNSW index and Postgres full-text search run side by side and are fused with configurable weighted reciprocal rank fusion (the default) or distribution-based score fusion (DBSF), so paraphrases and exact identifiers (hostnames, ticket numbers) both work.
- Change detection is a poll, not a watcher, because inotify does not propagate over NFS or SMB. The indexer stats every file each interval, hashes only files whose mtime or size moved, and re-embeds only files whose content changed.
- Exclusions are enforced by path before any read:
.obsidian/,.trash/,.git/, Synology#recycle/@eaDir/@tmp/, AppleDouble._*, dotfiles, non-.mdfiles, any directory containing a.noindexfile, and any note with frontmatterindex: false. - Links resolve like Obsidian does.
[[Areas/note]]resolves by path;[[note]]resolves if exactly one note in the vault has that basename, and stays unresolved (recorded,NULLtarget) when two do. Deleting one of two duplicates re-resolves the other the next time it is indexed. - The model is a schema fact. The embedding model and dimension are recorded in the database on first run; a different model refuses to index until
--reindex.
Evaluating search quality (brain-eval)
Build with npm run build, then run with the same database, collection and embedding
configuration as the index:
node dist/eval.js --golden /private/golden.json --grid --out /private/runs/grid.json
# Or evaluate one configuration alongside the baseline:
node dist/eval.js --golden /private/golden.json --config '{"wText":1.5,"perNoteCap":2}' --out /private/runs/candidate.json
# npm run eval -- <the same arguments> also works.
The output directory must already exist. The CLI opens the database read-only: it never
applies schema changes, reindexes or writes to the index. The embedding model and dimension
must match the stored metadata, and every expected note must already exist in the collection.
Every query is embedded once and its vector reused across configurations. The fixed grid
contains 40 configurations, starting with the v1.8.0 baseline rrf-k60-v1-t1-cap0.
The CLI ignores ambient SEARCH_* ranking settings; --config merges only the supplied
fields over the baseline. Invalid ranking environment settings fail server startup.
Golden files are private evaluation data, with exactly these keys:
{
"version": 1,
"created": "2026-01-01",
"notes": "Synthetic example only",
"queries": [
{
"id": "example-1",
"category": "exact-id",
"query": "synthetic search phrase",
"expected": ["Example.md"]
}
]
}
Query ids must be unique, query strings non-empty, and expected lists non-empty with
unique .md paths. Categories are exact-id, paraphrase, decision-why, multi-hop,
and recent, reported in that order. Unknown keys are rejected. Keep golden sets and
evaluation reports outside a public code repository.
Metrics use unique note paths in result order, after requesting eight chunks:
mrr: mean reciprocal rank of the first expected note, or zero for a miss.recall5: a hit rate at 5, meaning any expected note in the first five notes.ndcg8: binary-relevance normalised discounted cumulative gain at eight notes.distinctNotes: mean number of distinct notes returned. Metrics are also broken down by category, and the JSON report rounds them to four decimal places.
A candidate must improve MRR by at least 0.0300, preserve every category's hit rate,
and preserve every query whose baseline hit was within five. Comparisons round metrics
to integer ten-thousandths. Passing candidates are ordered by higher MRR, hit rate, then
NDCG; ties prefer fewer changed ranking fields, then the lexicographically smaller id.
If none passes, the baseline wins with changed: false. The JSON includes the golden
file's SHA-256, per-query note lists, gate reasons, and the winning environment variables;
stdout prints the ranking table, category comparison and final WINNER line.
Exit codes: 0 for a completed report (including no ranking change), 2 for input
problems (arguments, golden validation, model mismatch or missing expected paths), and
1 for other runtime failures (database, embedding service or output writes).
Searches require pgvector 0.8.0 or newer: each transaction uses strict-order iterative
HNSW scans and filtered searches raise hnsw.ef_search to 100. Default RRF scores match
v1.8.0, with exact ties ordered by chunk position then id. DBSF scores range from zero
to the sum of the two leg weights.
Frontmatter the index understands
---
title: ubuntubox — NFS mount gate # else first H1, else file name
type: area # filterable: semantic-search { type }
status: active # filterable: semantic-search { status }
tags: [infra, nas] # filterable: semantic-search { tags }
summary: One line that answers "what is this note about".
index: true # false = never indexed
---
Invalid frontmatter is reported, not hidden. A note whose --- block is not valid YAML (the usual cause is an unquoted value containing :, such as summary: Finished it: all done; quote it: summary: "Finished it: all done") is still indexed by its body, but without its metadata. The indexer logs WARN invalid frontmatter in <path>: <YAML error> when it indexes such a note and again at every start, each pass line carries invalid_frontmatter=<count>, and index-status returns invalidFrontmatter: { count, notes: [{ path, error }] } (first 20). reindex-note returns a warning. Fix the note and the flag clears on the next pass.
Setup
- Database: any Postgres 16+ with pgvector. The schema is created on first start from
sql/001_schema.sql; nothing to run by hand. HNSW index builds need shared memory: give the Postgres containershm_size: 1g. - Embeddings:
ollama pull nomic-embed-text(768-d, 8k context) orbge-m3(1024-d). - Server: add
DATABASE_URL,OLLAMA_URL,EMBED_MODEL(and optionallyINDEX_COLLECTION) to the server's environment. The/healthendpoint reportssemanticSearch: enabled. - Indexer: run the same image again with
command: ["node", "dist/indexer.js"], the same environment, and the vault mounted read-only.node dist/indexer.js --once --verbosedoes a single pass and exits non-zero if any file failed.
services:
obsidian-mcp:
image: ghcr.io/smith-and-web/obsidian-mcp-server:latest
environment:
VAULT_PATH: /vault
API_KEY: ${API_KEY}
DATABASE_URL: postgres://brain:${DB_PASSWORD}@brain-db:5432/brain
OLLAMA_URL: http://ollama:11434
EMBED_MODEL: nomic-embed-text
INDEX_COLLECTION: secondbrain
volumes:
- /path/to/vault:/vault:rw
ports: ["127.0.0.1:3001:3000"]
obsidian-indexer:
image: ghcr.io/smith-and-web/obsidian-mcp-server:latest
command: ["node", "dist/indexer.js"]
environment:
VAULT_PATH: /vault
DATABASE_URL: postgres://brain:${DB_PASSWORD}@brain-db:5432/brain
OLLAMA_URL: http://ollama:11434
EMBED_MODEL: nomic-embed-text
INDEX_COLLECTION: secondbrain
INDEX_INTERVAL_SECONDS: "60"
volumes:
- /path/to/vault:/vault:ro
brain-db:
image: pgvector/pgvector:0.8.2-pg16
shm_size: 1g
environment:
POSTGRES_DB: brain
POSTGRES_USER: brain
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./brain-db:/var/lib/postgresql/data
ollama:
image: ollama/ollama:latest
volumes:
- ./ollama:/root/.ollama
Indexer CLI
| Command | Does |
|---|---|
node dist/indexer.js | Run forever, one pass every INDEX_INTERVAL_SECONDS; stops cleanly on SIGTERM |
node dist/indexer.js --once | One pass; exit 1 if any file errored |
node dist/indexer.js --reindex | Drop this collection and rebuild (required after changing EMBED_MODEL) |
npx -p @smith-and-web/obsidian-mcp-server obsidian-mcp-indexer | The same indexer from the npm package (also accepts --once, --reindex, --verbose) |
--verbose / INDEX_VERBOSE=true | Log every file indexed or removed |
Security notes
- The indexer needs only read access to the vault; mount it
:ro. - Chunk text is stored in Postgres in clear. Treat the database as you treat the vault: same network boundary, same backup policy, and don't index material that should not share an access key with the rest of the vault (use
index: falseor a.noindexdirectory). - Embeddings never leave the Ollama host. No external API is involved anywhere in this feature.
API Endpoints
| Endpoint | Method | Description |
|---|---|---|
/health | GET | Health check |
/sse | GET | SSE endpoint for MCP |
/sse | POST | Direct MCP protocol calls |
/message | POST | SSE transport messages |
Authentication
The server supports optional API key authentication. When API_KEY is set, all /sse and /message endpoints require authentication. The /health endpoint remains public.
Enabling Authentication
Set the API_KEY environment variable:
# Docker
docker run -d \
--name obsidian-mcp \
-v /path/to/vault:/vault:rw \
-p 3001:3000 \
-e VAULT_PATH=/vault \
-e API_KEY=your-secret-key \
ghcr.io/smith-and-web/obsidian-mcp-server:latest
# npx
API_KEY=your-secret-key VAULT_PATH=/path/to/vault npx @smith-and-web/obsidian-mcp-server
Authentication Method
Clients send the key in the Authorization header:
Authorization: Bearer your-secret-key
The ?api_key= query parameter was removed in 1.6.0 (it ends up in access logs and client history). Clients that used it must switch to the header, for example mcp-remote with --header "Authorization: Bearer your-secret-key".
Tailscale login (optional second factor)
Behind tailscale serve on loopback, set REQUIRE_TAILSCALE_LOGIN=you@example.com. Requests then also need the Tailscale-User-Login header that serve adds for tailnet users; anything else gets 403. Do not rely on this if the port is reachable any other way, because the header can be forged by a direct client.
Client Configuration with API Key
Claude Desktop / Cursor (via mcp-remote):
{
"mcpServers": {
"obsidian": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"https://your-domain.com/sse",
"--header", "Authorization: Bearer your-secret-key"
]
}
}
}
Claude Code:
claude mcp add --transport sse obsidian https://your-domain.com/sse --header "Authorization: Bearer your-secret-key"
Security Considerations
- Generate strong keys: Use a random string of at least 32 characters
- HTTPS required: Always use HTTPS when exposing the server remotely to prevent key interception
- File Access: The server has full read/write access to the mounted vault, but every path is confined to it (paths that escape the vault, such as
../../etc/passwd, are rejected; fixed in 1.4.1) - Bind address: the server listens on all interfaces. In Docker, publish to loopback (
127.0.0.1:3001:3000) unless remote access is intended - CORS: Currently allows all origins. Restrict in production if needed
- Key in logs: only send the key in the
Authorizationheader; the old?api_key=query parameter was removed because URLs end up in access logs - Network security: Consider additional layers like VPN or firewall rules for sensitive vaults
Deployment with NGINX Proxy Manager
For remote access with SSL:
- Add a Proxy Host:
- Domain:
obsidian.yourdomain.com - Forward Hostname:
obsidian-mcp(container name) - Forward Port:
3000 - Websockets are not required (SSE is plain HTTP); the Advanced block below is what matters
- Domain:
- SSL Tab: Request Let's Encrypt certificate
- Advanced Tab (for SSE):
proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off;
Private access with Tailscale
If your clients are on a Tailscale tailnet you can skip the public reverse proxy entirely. Keep the container on loopback and let tailscale serve publish it with a tailnet-only HTTPS certificate:
docker run -d --name obsidian-mcp -v /path/to/vault:/vault:rw -p 127.0.0.1:3001:3000 \
-e API_KEY=your-secret-key -e REQUIRE_TAILSCALE_LOGIN=you@example.com \
ghcr.io/smith-and-web/obsidian-mcp-server:latest
sudo tailscale serve --bg --https=443 http://127.0.0.1:3001
Clients then use https://<machine>.<tailnet>.ts.net/sse with the Authorization: Bearer header. Never enable Tailscale Funnel for this server. See Tailscale login for what REQUIRE_TAILSCALE_LOGIN does and does not protect against.
Troubleshooting
Container Issues
# View logs
docker-compose logs -f obsidian-mcp
# Restart
docker-compose restart
# Rebuild
docker-compose up -d --build
Vault Access
# Check host mount
ls -la /path/to/your/vault
# Check container access
docker exec obsidian-mcp ls -la /vault
Connection and auth errors
401 Unauthorized: missing or wrongAuthorization: Bearer <key>header (the?api_key=query parameter is no longer accepted).403 Forbidden:REQUIRE_TAILSCALE_LOGINis set and the request did not arrive throughtailscale serveas that user.Path escapes vault: the requested path resolves outsideVAULT_PATH; use a path relative to the vault.- Search results are missing a note's
summary, tags or type filters: checkindex-statusforinvalidFrontmatter(or the indexer log forWARN invalid frontmatter); the note's YAML is invalid and was indexed without its metadata. - SSE connection drops behind a reverse proxy: turn proxy buffering off (see the NGINX block above), check the certificate, and verify the firewall allows the port.
- Claude Desktop starts but shows no tools: the server must already be running and the config must use
mcp-remote(the server has no stdio mode).
Development
Quick Start
# Clone and install
git clone https://github.com/smith-and-web/obsidian-mcp-server.git
cd obsidian-mcp-server
make install
# Run with example vault
make dev
# Test connection
make test-connection
Available Commands
Run make help to see all available commands:
Development:
make install Install dependencies
make dev Run server with hot-reload
make start Run server in production mode
make test-connection Test server connectivity
Docker:
make docker-build Build Docker image
make docker-up Start Docker container
make docker-down Stop Docker container
make docker-logs View container logs
make docker-restart Restart container
make docker-shell Open shell in container
API Testing with Bruno
The repository includes a Bruno collection for testing the core tools (the four semantic-search tools need DATABASE_URL and a running index).
- Install Bruno (free, open-source API client)
- Open the collection from
./bruno/obsidian-mcp - Select the
localorremoteenvironment - Run requests to test each tool
The collection is organized by category:
health/- Server health and tool listingnotes/- Note CRUD operationsdirectories/- Directory operationsfrontmatter/- Frontmatter operationstags/- Tag managementsearch/- Search and find-replacelinks/- Backlinks and broken linkssections/- Section-based operations
Example Vault
An example vault is included in ./examples/test-vault/ for development and testing. It includes sample notes with tags, links, and sections to test all features.
Contributing
See CONTRIBUTING.md for guidelines.
License
MIT License - see LICENSE for details.
Related Projects
- Model Context Protocol - Protocol specification
- Obsidian - Knowledge base application
- Claude - AI assistant by Anthropic
Frequently asked questions
What is Obsidian MCP Server?
Remote Obsidian vault management over SSE: notes, directories, frontmatter, tags, search and link operations, deployable with Docker.
How do I install Obsidian MCP Server?
Follow the installation steps in its documentation, shown on this page and in its repository.
Is Obsidian MCP Server open source?
Yes. Its code is on GitHub under the MIT license.
Who maintains Obsidian MCP Server?
It is published by smith-and-web. AI.tn lists it but does not maintain or endorse it.
From the community awesome-mcp-servers lists and the project's GitHub repository. Listing is not an endorsement: review a server's code and permissions before giving it access to your data.