Skip to main content
Glama
JasonSooter

obsidian-mcp

by JasonSooter

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.

  1. 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...)
  2. Pre-create the state directory, owned by uid 1000. The container runs as that user and refuses to start if /state is unwritable — deliberately, since serving the write tools with no undo is the situation the journal prevents:

    mkdir -p state && sudo chown -R 1000:1000 state

    The vault folder must be readable and writable by uid 1000 as well.

  3. Run it. OBSIDIAN_VAULTS names 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-stopped
    docker 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

OBSIDIAN_VAULTS

—

Required. Comma-separated vault folder names under OBSIDIAN_VAULT_ROOT

OBSIDIAN_MCP_LOGIN_PASSWORD

—

Required. Login password, ≥12 characters

OBSIDIAN_MCP_TOTP_SECRET

—

Required. Base32 TOTP secret (the setup key, not a 6-digit code)

OBSIDIAN_MCP_PUBLIC_URL

http://localhost:<port>

The public base URL clients reach. Becomes the OAuth issuer: set it exactly

OBSIDIAN_DEFAULT_VAULT

first of OBSIDIAN_VAULTS

Vault used when a tool call names none

OBSIDIAN_VAULT_ROOT

/vaults

Directory holding the vault folders

OBSIDIAN_MCP_STATE_DIR

/state

Version journal, OAuth database, audit log

OBSIDIAN_MCP_HOST / OBSIDIAN_MCP_PORT

0.0.0.0 / 8780

Listen address

OBSIDIAN_MAX_FILE_BYTES

2000000

Files larger than this are refused

OBSIDIAN_JOURNAL_RETENTION_DAYS

30

Full undo history kept this long, then thinned to daily

OBSIDIAN_MCP_ENV

production

deployment.environment.name on telemetry

OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_HEADERS

unset

Optional OTLP log export. Unset = log to stdout only

QUARTZ_ENABLED

false

Turn on the Quartz publishing tools

QUARTZ_REPO_PATH

/quartz

Checkout of your Quartz site repo

QUARTZ_SSH_KEY_PATH

unset

Deploy key used to push the site repo

QUARTZ_GIT_REMOTE / QUARTZ_GIT_BRANCH

origin / main

Where publishing pushes

QUARTZ_GIT_AUTHOR

obsidian-mcp <obsidian-mcp@localhost>

Commit author for publishes

QUARTZ_BUILD_LOCALLY

true

Run npx quartz build before pushing. The image has no Node, so set false when your host builds on push

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_versions and restore_version undo any append, edit, overwrite, or delete. vault_delete also 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.log of 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

vault_read

frontmatter, body, tags, links, and mtime_ns

vault_list

by folder; sorted by modified (default), created, or path

vault_get_document_map

vault outline: paths, titles, tags, headings. The orientation tool

search_simple

literal text search

search_query

regex, plus tag and properties (frontmatter) filters

tag_list

every tag with a usage count

vault_append

end of file, or end of a named heading section. The capture tool

vault_patch

precise: heading (incl. Parent::Child), ^block-id, or frontmatter key × append/prepend/replace

vault_write

mode="create" (default, refuses to clobber) or "overwrite"; takes expected_mtime_ns

vault_move

rename/move, repointing every backlink by default

vault_copy

duplicate a note; its links are left pointing where they pointed

vault_delete

moves to .trash/; journalled

resolve_links / list_backlinks

wikilink graph, shortest-unique-path resolved

list_versions / restore_version

undo, including for deleted notes

set_publish_status / list_published / preview_publish / publish_site

Quartz; only when QUARTZ_ENABLED=true

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

command_list, command_execute

run Obsidian commands — there is no app to run them in

open_file

opens a file in the Obsidian UI

active_file_get_path

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.

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.net

A 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/mcp

Claude 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

  1. 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_hosts

    Add the .pub at 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 set QUARTZ_SSH_KEY_PATH to its path inside the container.

  2. Clone the site repo into the directory mounted at /quartz, then set QUARTZ_ENABLED=true.

  3. No Node in the image. Set QUARTZ_BUILD_LOCALLY=false and 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

publish_site reports dozens of updates with no edits

The clone is stale. git pull it — published_at() compares against local commit times.

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 docker logs obsidian-mcp for POST /mcp first. If the request never arrived, the failure is between Anthropic's proxy and your public ingress, not your server — confirm with curl https://<your-host>/healthz, which will still answer. Affects every tool, not just slow ones.

A note is never published

It contains a Dataview block — check skipped in the result.

Frontmatter dates keep changing

Fixed: PyYAML was coercing 2024-06-04T19:23:37-06:00 into a datetime and re-emitting it with a space. Loader and dumper now leave date-like scalars alone.

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:

  • unwrap works 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

tool call

every tool invocation

tool, status, error, duration_ms, mutation, vault

vault mutation

every write, via the journal

tool, path, version_id, plus per-tool fields

auth failure

a token that did not verify

reason (never any part of the token)

server started

boot

vaults, quartz_enabled, port

vault stats

boot

notes

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.record drops 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 /state went 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_id needed to undo it with restore_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-mcp

  • What changed and when: tail -f state/audit.log (JSON lines)

  • Recover a note: list_versions then restore_version, or by hand from state/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

MIT

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Enables Claude Code read/write access to an Obsidian vault, including creating, editing, searching, and browsing notes.
    8
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables 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 npm
    Apache 2.0
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables 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 npm
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables bidirectional interaction with Obsidian vaults, allowing reading, writing, and organizing notes through Claude.
    MIT