obsidian-mcp
Provides read/write access to an Obsidian vault, enabling tools for reading, searching, creating, editing, moving, deleting, and restoring Markdown notes, as well as link resolution and version history.
Click on "Deploy 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-mcpSearch my vault for notes tagged #meeting and summarize the action items"
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-mcp
An MCP server that gives Claude — Claude Code, Claude Desktop, claude.ai and the mobile apps — read/write access to an Obsidian vault, over Streamable HTTP.
It is vault-native: it works directly on the markdown files, so it needs no Obsidian app, no plugin and no GUI. Run it on a headless server next to a copy of your vault kept in sync by Dropbox, rclone, Syncthing, Obsidian Sync or git, and one URL works from every Claude client.
Why not the existing Obsidian MCP servers
The community server (MarkusPfundstein/mcp-obsidian) and Obsidian's own official CLI are both good, and both are the right tool on a desktop. Neither can serve a headless machine, for the same two reasons:
Needs Obsidian running | Transport | |
MarkusPfundstein/mcp-obsidian | Yes — it is an HTTP client for the Local REST API plugin | stdio |
Obsidian official CLI (1.12+) | Yes — documented as "a remote control for a running app"; launches it if absent | n/a |
Obsidian Headless (beta) | It is a Sync client: replicates files, does not run the app or its plugins | n/a |
Quartz Syncer plugin CLI | Yes — "Obsidian must be running for CLI commands to work" | n/a |
this server | No — reads the markdown on disk | Streamable HTTP |
A headless server has no GUI session for Obsidian to run in. And stdio has no listening socket, so it cannot serve a phone or a browser at all.
Keep using a plugin-based server on your desktop if you like — same vault, different access path, no conflict.
Related MCP server: obsidian-mcp-server
Quick start
You need Docker, a folder containing your vault(s), and an authenticator app.
Create the login secrets. The server refuses to start without a password (12+ characters) and a TOTP secret:
cp secrets.env.example secrets.env # OBSIDIAN_MCP_LOGIN_PASSWORD: e.g. `openssl rand -base64 24` # OBSIDIAN_MCP_TOTP_SECRET: a base32 secret; add the same one to your # authenticator (1Password, Aegis, Google Authenticator...)Pre-create the state directory, owned by uid 1000. The container runs as that user and refuses to start if
/stateis unwritable — deliberately, since serving the write tools with no undo is the situation the journal prevents:mkdir -p state && sudo chown -R 1000:1000 stateThe vault folder must be readable and writable by uid 1000 as well.
Run it.
OBSIDIAN_VAULTSnames the vault folder(s) under the mounted root:# compose.yaml services: obsidian-mcp: image: ghcr.io/jasonsooter/obsidian-mcp:main env_file: secrets.env environment: OBSIDIAN_VAULTS: MyVault OBSIDIAN_MCP_PUBLIC_URL: https://<your-host>.<your-tailnet>.ts.net volumes: - /path/to/vaults:/vaults # contains MyVault/ - ./state:/state ports: - "127.0.0.1:8780:8780" restart: unless-stoppeddocker compose up -d curl -s localhost:8780/healthz # {"status":"ok","vaults_reachable":true}
Images are published for linux/amd64 and linux/arm64: :main tracks the
main branch, :sha-<commit> pins a build. Pin by digest in production.
Configuration
Variable | Default | |
| — | Required. Comma-separated vault folder names under |
| — | Required. Login password, ≥12 characters |
| — | Required. Base32 TOTP secret (the setup key, not a 6-digit code) |
|
| The public base URL clients reach. Becomes the OAuth |
| first of | Vault used when a tool call names none |
|
| Directory holding the vault folders |
|
| Version journal, OAuth database, audit log |
|
| Listen address |
|
| Files larger than this are refused |
|
| Full undo history kept this long, then thinned to daily |
|
|
|
| unset | Optional OTLP log export. Unset = log to stdout only |
|
| Turn on the Quartz publishing tools |
|
| Checkout of your Quartz site repo |
| unset | Deploy key used to push the site repo |
|
| Where publishing pushes |
|
| Commit author for publishes |
|
| Run |
Port 8780, not 8770, so it can run beside anki-mcp on 8770. Under some Docker runtimes (Colima, for one) an occupied host port fails silently — the container looks healthy while every request reaches the other service.
The rule that makes this safe
The vault is shared mutable state: your sync tool writes to it whenever it likes, and this server is not holding a lock. So:
Nothing is cached. Every tool call reads fresh from disk.
Writes are atomic: temp file in the same directory,
fsync,os.replace(). Temp files are named.obsidian-mcp.tmp.*— tell your sync tool to ignore that pattern.Every mutation is snapshotted first into
/state/versions/, outside the vault.list_versionsandrestore_versionundo any append, edit, overwrite, or delete.vault_deletealso moves the note to.trash/rather than unlinking it.
The only action here that cannot be undone is publish_site, because content on
a public website is public. It defaults to dry_run=True.
Security posture
This runs as a single profile with every tool, typically published to the internet (e.g. with Tailscale Funnel), because that is what makes one connector URL work on every device — phones and claude.ai connect from Anthropic's infrastructure and have no route into a private network.
That means vault_write, vault_delete, and publish_site are internet-reachable
behind one login. Controls, none of which cost capability:
OAuth 2.1 with a password and TOTP login; the server refuses to start without both (see Authentication).
Version journal — everything but publishing is reversible.
Hard path confinement: symlinks are resolved before the containment check, so a symlink planted in the vault is rejected exactly like
../../etc/passwd. Extension allowlist (.md,.markdown,.txt,.canvas);.obsidian/is never readable, listable, or searchable.publish_site(dry_run=True)by default.Repo-scoped deploy key for publishing, never a personal access token — a container compromise is not an account compromise.
Append-only
/state/audit.logof every mutation, never readable via any tool.QUARTZ_ENABLED=false(the default) unregisters the publishing tools entirely.
Tools
Tool names deliberately match the plugin-based Obsidian connector, so prompts and habits carry across both servers.
Tool | Notes |
| frontmatter, body, tags, links, and |
| by folder; sorted by |
| vault outline: paths, titles, tags, headings. The orientation tool |
| literal text search |
| regex, plus |
| every tag with a usage count |
| end of file, or end of a named |
| precise: |
|
|
| rename/move, repointing every backlink by default |
| duplicate a note; its links are left pointing where they pointed |
| moves to |
| wikilink graph, shortest-unique-path resolved |
| undo, including for deleted notes |
| Quartz; only when |
Beyond the plugin-based connector
resolve_links, list_backlinks, list_versions, and restore_version have no
equivalent there — the link graph and the undo journal are additions.
What cannot be done vault-natively
Four tools on the plugin-based connector drive the Obsidian user interface, so they have no meaning without a running app and are deliberately absent:
Missing | Why |
| run Obsidian commands — there is no app to run them in |
| opens a file in the Obsidian UI |
| asks which file the UI currently has open |
Everything else that connector does is covered above. Use the plugin-based connector when you want to drive the app; use this one to work with the vault's contents.
Link resolution
list_backlinks and resolve_links implement Obsidian's own rules, not filename
matching: an exact vault-relative path wins, then a path relative to the linking
note's folder, then a unique basename. A bare [[Name]] with several matches
is ranked by proximity (same folder first, then shortest path) and returns
ambiguous_candidates listing the alternatives — check that field before
treating a result as certain.
Exposing it to Claude
claude.ai and the mobile apps need a public HTTPS URL on port 443; a non-standard port is rejected by claude.ai's connector validator ("Couldn't reach this address") even when it is publicly reachable. Any HTTPS reverse proxy works. With Tailscale:
tailscale funnel --bg 8780 # https://<your-host>.<your-tailnet>.ts.netA Tailscale node has exactly one port 443, so running this beside another
public service on the same machine means giving it its own node — e.g. a
tailscale/tailscale sidecar container with network_mode: service:obsidian-mcp,
TS_USERSPACE=true, and a serve.json proxying to 127.0.0.1:8780. The auth
key must be reusable and non-ephemeral, or the node vanishes on restart
and takes its hostname and certificate with it. Recreate the two containers
together: the sidecar shares the server's network namespace, and recreating one
alone orphans the other.
OBSIDIAN_MCP_PUBLIC_URL must equal the public URL exactly — it becomes the OAuth
issuer, and a stale value breaks discovery silently.
Three endpoints are unauthenticated by necessity: /healthz, the two
/.well-known/oauth-* documents, and /register. The login page is the only
thing guarding the vault, which is why it is password and TOTP.
Authentication
OAuth 2.1, and only OAuth. It is the sole scheme because it is the only one every client can use: Claude Code and Desktop can send headers, but claude.ai and mobile connectors run OAuth discovery and have no field for one. A second static-token path would have reached no client that this does not.
The OAuth server is shared in design with anki-mcp — same flow, same threat model, one user each. The MCP server is itself the authorization server; there is no upstream IdP because there is exactly one user:
Claude ──/authorize──► this server ──redirect──► /login (password + TOTP)
│
Claude ◄──?code=…──────────────────────────────────┘
│
└──/token──► access token (1h) + refresh token (30d)The server refuses to start without both login factors. That is deliberate: the login page is the only thing between the public internet and the vault, so a half-configured deploy should fail at boot rather than serve with weaker auth.
Protections on the login page: constant-time comparison, 5 failures per 15 min
per IP keyed on the nearest trusted proxy hop, TOTP replay rejection (the
accepted counter is remembered), ±1 step of clock skew, and errors that never
reveal which factor was wrong. With telemetry on, every attempt is logged as an
oauth login event with the client IP — never the submitted secrets.
Connecting a Claude client
One URL for everything: https://<your-host>.<your-tailnet>.ts.net/mcp. Every
client runs the OAuth flow itself — no token is pasted anywhere.
Claude Code (user scope):
claude mcp add --scope user --transport http obsidian \
https://<your-host>.<your-tailnet>.ts.net/mcpClaude Code opens a browser for the login on first use.
Claude Desktop — claude_desktop_config.json:
"obsidian": {
"command": "npx",
"args": ["mcp-remote@latest", "https://<your-host>.<your-tailnet>.ts.net/mcp"]
}mcp-remote performs the OAuth dance when no --header is given. If Desktop
can't find npx (GUI apps don't inherit an nvm PATH), use an absolute path.
Restart Desktop after editing.
claude.ai and mobile — Settings → Connectors → Add custom connector, paste
the same URL. Claude registers itself via /register (dynamic client
registration), then sends you to the login page. On iOS the TOTP field is marked
autocomplete="one-time-code", so password managers offer the code directly.
Quartz publishing (optional)
Publishes notes flagged publish: true to your own Quartz
site repo, which your host (Vercel, Netlify, GitHub Pages...) builds on push.
Off unless QUARTZ_ENABLED=true.
Two publishers, neither the owner
The site's content/ directory is written by both this server and the
Quartz Syncer Obsidian plugin,
and that constraint drives the whole design. Syncer runs inside Obsidian (so it
can compile Dataview, but needs the app open); this server runs headless (so it
works from a phone, but cannot).
They format differently and cannot be made byte-identical — Syncer parses
every note to an mdast tree and re-serialises it through
mdast-util-to-markdown, so its output reflects that serializer's escaping,
list indentation, blank-line and emphasis rules. Reproducing that would mean
running the same serializer, in Node, pinned to the same options, and
re-breaking on every Syncer release.
So parity is not the goal. The goal is that neither publisher rewrites the other's files. That is achieved by never asking "do these bytes match?" and instead asking:
Has this note been edited since it was last published?
Publisher.published_at() reads the last commit time of each file under
content/, and a note is republished only if its vault mtime is newer (plus a
15-minute grace window, because the plugin writes the file and commits a moment
later). Whoever published last set that timestamp, whatever formatting they
used. This took spurious updates from 36 to 0.
Two settings are matched, because they are one line each and the most visible
difference: emphasis: "_" and bullet: "-". Measured honestly, they close
about 3% of the remaining formatting gap — worth having, not worth mistaking for
progress toward parity.
What is skipped
Notes containing ```dataview/```datacore blocks are not
published by this server and are listed under skipped in the result. Syncer
executes those through Dataview's own API (tryQueryMarkdown, executeJs) and
inlines the result; that API is bound to Obsidian's app, vault and
metadataCache, so there is nothing to call here. Publishing them would replace
generated content with the raw query — visible breakage on the live page.
```query blocks are not skipped: those are Obsidian core search
blocks, which Syncer passes through verbatim too.
Setup
Deploy key, scoped to the site repo only — never a personal access token:
mkdir -p secrets ssh-keygen -t ed25519 -N "" -f secrets/quartz_deploy_key ssh-keyscan github.com > state/known_hostsAdd the
.pubat Settings → Deploy keys → Add, with Allow write access ticked. Without that tick the clone succeeds and only the push fails, which looks like everything working until it doesn't. Mount the key and setQUARTZ_SSH_KEY_PATHto its path inside the container.Clone the site repo into the directory mounted at
/quartz, then setQUARTZ_ENABLED=true.No Node in the image. Set
QUARTZ_BUILD_LOCALLY=falseand let your host build on push. The trade-off is explicit: a local build is a gate that stops a broken note from shipping; the host's build catches it after the push instead.
Publishing
list_published what is flagged
publish_site() dry run: stage, diff, push nothing <- always first
publish_site(dry_run=False)publish_site is the only irreversible action this server has.
Troubleshooting
Symptom | Cause |
| The clone is stale. |
Push fails, clone worked | Deploy key added without Allow write access. |
Public URL times out; container looks healthy | A Tailscale sidecar sharing the server's network namespace was recreated alone. Recreate both together. |
502 from the MCP proxy | Check |
A note is never published | It contains a Dataview block — check |
Frontmatter dates keep changing | Fixed: PyYAML was coercing |
Telemetry (optional)
Set OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS to ship
structured logs over OTLP — Grafana Cloud's OTLP setup wizard (Connections →
Add new connection → OpenTelemetry) generates both. Every attribute is queryable
as structured metadata in Loki:
{service_name="obsidian-mcp", deployment_environment_name="production"}
| tool="vault_append" | status="error"The endpoint must be the base .../otlp URL, not .../otlp/v1/logs: the
Python SDK appends the signal path itself. The header stays percent-encoded
(Basic%20...) — the SDK decodes it per the OTel spec.
With no endpoint set, telemetry is a no-op and the server logs to stdout — an unreachable observability backend must never stop the vault being served.
dashboards/obsidian-mcp.json is a ready-made Grafana dashboard: import it via
Dashboards → New → Import. It expects a Loki datasource with uid
grafanacloud-logs (Grafana Cloud's default) and has an $env variable for
deployment_environment_name. Two things worth knowing if you edit it:
unwrapworks even though OTLP delivers every attribute as a string.last_over_time(… | unwrap …)returns one series per container instance, so a stat panel shows "N series" rather than a number. Aggregate it:max by (vault) (last_over_time(…)). Both stat panels here do.
Events
Body | When | Key attributes |
| every tool invocation |
|
| every write, via the journal |
|
| a token that did not verify |
|
| boot |
|
| boot |
|
Two conventions the dashboard depends on, both pinned by tests in
tests/test_core.py:
Attributes are scalars only. A list flattens into an unqueryable string, so
journal.recorddrops non-scalars from telemetry while still writing them to the audit file. Tools that record a list also record a*_count— that is what the panels read.Booleans are stringified (
mutation="true"), because LogQL structured metadata filters compare strings.
Attribute names are a contract
Beyond scalars-and-strings, attribute keys must not collide with Python's
logging.LogRecord fields (created, module, filename, name, ...).
journal.record() forwards each tool's fields straight into a log record, so a
collision raises out of the tool, not the logger — enabling telemetry once
broke vault_append outright because it recorded created. That field is now
note_created, emit() renames any reserved key to <key>_attr, and emit()
can no longer raise at all. All three are covered by tests.
Instrumentation is a @instrumented(...) decorator applied under @mcp.tool. It
uses functools.wraps so the SDK still generates each tool's JSON schema from
the real signature — there is a test for that specifically, because losing it
would empty every tool's parameters on the wire without any other symptom.
What the panels answer
Is it healthy? — call volume, error rate, p95 latency by tool, restarts, vault size. Restarts usually mean the vault mount or
/statewent away.Security and audit — auth failures, rejected paths (traversal, symlinks,
.obsidian/), and destructive/outward-facing calls. With one public URL and one login page, this row is the reason the dashboard exists.Usage — which tools actually get called, and errors by kind.
Vault activity — mutations over time, capture volume, most-changed notes. Every line carries the
version_idneeded to undo it withrestore_version.Publishing — real publishes (dry runs never reach the journal), notes added, build failures, and how often the undo journal was needed.
Note that a non-zero error count is not automatically bad: a refused traversal and a refused stale write both count as errors and are the server working.
Operations
Logs:
docker compose logs -f obsidian-mcpWhat changed and when:
tail -f state/audit.log(JSON lines)Recover a note:
list_versionsthenrestore_version, or by hand fromstate/versions/<vault>/<path>/Journal retention: everything for 30 days, then thinned to one snapshot per day (
OBSIDIAN_JOURNAL_RETENTION_DAYS). It is an undo buffer, not a backup — same disk as the vault, and your sync tool does not replicate it.
The server refuses to start if the vault is missing or /state is unwritable.
That is deliberate: serving vault_write and vault_delete with no undo is exactly
the situation the journal exists to prevent.
Development
uv sync # Python 3.12, from .python-version and uv.lock
uv run pytest
docker build -t obsidian-mcp --build-arg GIT_REVISION=$(git rev-parse HEAD) .Tests cover path confinement (traversal, symlink-out, symlinked parent, extension allowlist), Obsidian link resolution including the ambiguous-basename case, frontmatter-preserving appends and heading targeting, vault_patch's heading / nested-heading / block / frontmatter targeting, the link rewriting behind vault_move, the journal's snapshot/restore and version-id confinement, OAuth hardening, and telemetry conventions.
The search tools and list_backlinks shell out to ripgrep, which the image
installs; running the server outside Docker needs rg on PATH.
CI runs the tests and builds the image on every pull request, and publishes
ghcr.io/jasonsooter/obsidian-mcp on every push to main. Dependencies are
kept current by Renovate.
License
This server cannot be deployed
Maintenance
Related MCP Connectors
Search and reason over your Obsidian-style Markdown vault, right from ChatGPT.
Connect AI assistants to your GitHub-hosted Obsidian vault to seamlessly access, search, and analy…
Search, read, and safely update Markdown notes in your connected Phasoric knowledge vaults.
Read, edit, publish, and preview your pepita websites from Claude.
Related MCP Servers
- AlicenseAqualityCmaintenanceEnables Claude Code read/write access to an Obsidian vault, including creating, editing, searching, and browsing notes.8MIT
- AlicenseNot gradedqualityDmaintenanceEnables Claude Desktop to interact with an Obsidian vault through the Local REST API, allowing file listing, reading, searching, creating, and updating markdown notes.5,145 npmApache 2.0
- AlicenseNot gradedqualityDmaintenanceEnables reading, writing, searching, and managing an Obsidian vault through Claude, operating directly on markdown files via Node.js fs without requiring the Obsidian app.5,145 npmMIT
- AlicenseNot gradedqualityDmaintenanceEnables bidirectional interaction with Obsidian vaults, allowing reading, writing, and organizing notes through Claude.MIT