Metadata-Version: 2.5
Name: obsidian-remote-mcp
Version: 2.0.0
Summary: MCP server for Obsidian vaults — read, write, search, graph, canvas, kanban
Project-URL: Homepage, https://github.com/ykoellmann/obsidian-mcp
Project-URL: Repository, https://github.com/ykoellmann/obsidian-mcp
Project-URL: Issues, https://github.com/ykoellmann/obsidian-mcp/issues
License: MIT
License-File: LICENSE
Requires-Python: >=3.12
Requires-Dist: fastmcp>=3.4.7
Requires-Dist: filelock>=3.13
Requires-Dist: python-frontmatter>=1.1
Requires-Dist: pyyaml>=6.0
Requires-Dist: watchdog>=4.0
Description-Content-Type: text/markdown

# obsidian-mcp

[![CI](https://github.com/ykoellmann/obsidian-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/ykoellmann/obsidian-mcp/actions/workflows/ci.yml)

An MCP (Model Context Protocol) server for [Obsidian](https://obsidian.md) vaults. Connects Claude (or any MCP client) directly to your vault — read, write, search, navigate links, and manage notes, canvases, and kanban boards.

## Why obsidian-mcp?

The official Obsidian MCP plugin requires the Obsidian desktop app to be running and only works on the same machine. **obsidian-mcp is a standalone server** — no Obsidian app needed.

The intended setup is to host obsidian-mcp on a server or NAS where your vault is continuously synced (via [Syncthing](https://syncthing.net), [git](https://github.com/denolehov/obsidian-git), [rclone](https://rclone.org), or [Obsidian Sync](https://obsidian.md/sync)). Claude then connects to that server over the network, so:

- **Always up to date** — the server sees every change your Obsidian app writes, immediately
- **Access from anywhere** — connect from Claude Desktop, Claude Code, or any MCP client on any machine, without the vault being present locally
- **Multiple clients** — several Claude sessions can read the vault simultaneously; writes are serialized with per-file locking
- **No app dependency** — the server runs headless and starts automatically (systemd, Docker, etc.)

```
[Obsidian app]  ──sync──►  [vault on server]  ◄──MCP──  [Claude on any machine]
  (phone/laptop)              (NAS / VPS)                  (Claude Desktop / Code)
```

## Features

- **Read & Search** — read notes, search full-text (exact/regex/fuzzy, optionally combined with a frontmatter filter or scoped to filenames), render embedded transclusions, inspect note outlines, list every file in the vault regardless of type
- **Duplicate prevention** — `find_similar_notes_tool` ranks notes by TF-IDF similarity so a new note doesn't duplicate an existing one under different wording
- **Schema linting** — `lint_schema_tool` validates frontmatter against the enums declared in your own `_AI_INSTRUCTIONS.md`, plus an optional cron-friendly health-check script
- **Write** — create/overwrite notes (with automatic frontmatter preservation, dry-run previews, and unified diffs), patch sections or anchor-less body text, append content, update frontmatter (single or batch), manage tags, move notes with automatic wikilink rewriting
- **Folders** — list (optionally recursive with a full tree dump), create, delete, rename folders; renaming rewrites path-based wikilinks vault-wide
- **Query & Graph** — backlinks, broken links, orphan detection, BFS link graph, vault stats, task collection across vault
- **Dataview-like queries** — filter notes by tags, status, frontmatter fields (exact match or `$ne`/`$in`/`$nin`/`$exists` operators), or inline fields (`key:: value`)
- **Audit log** — every write-tool call is recorded ({timestamp, tool, path, summary}); `get_audit_log_tool` queries it (pass `path=` for one note's history)
- **Periodic Notes** — read/preview daily, weekly, monthly, quarterly, yearly journal notes from templates
- **Canvas** *(opt-in via `ENABLE_CANVAS`)* — read, create, and patch Obsidian Canvas (`.canvas`) files
- **Excalidraw** *(opt-in via `ENABLE_EXCALIDRAW`)* — read, create, and patch Obsidian Excalidraw (`*.excalidraw.md`) drawings
- **Kanban** *(opt-in via `ENABLE_KANBAN`)* — read, create, and manipulate Obsidian Kanban boards (columns and cards)
- **Bases** *(opt-in via `ENABLE_BASES`)* — read, create, and patch Obsidian Bases (`.base`) files — YAML-defined table/cards/list views over existing frontmatter properties
- **Attachments** — list, read (text or base64), and add binary files
- **Two auth variants** — a static API key (Claude Code, Desktop, curl) and, optionally, GitHub OAuth (claude.ai Web/Mobile Custom Connector) — usable independently or at the same time
- **Multi-vault** *(opt-in via `VAULTS_CONFIG`)* — serve several fully isolated vaults from one deployment, each identity (API key or GitHub login) mapped to only the vault(s) it may access
- **Templates** — render Obsidian templates with built-in (`{{date}}`, `{{title}}`, …) and custom variables
- **MCP Resources** — expose vault notes, stats, and tags as MCP resources for direct context injection
- **MCP Prompts** — `weekly_review`, `daily_note` starting points for common workflows

## Installation

**Via uvx (no clone needed):**
```bash
VAULT_PATH=/your/vault uvx obsidian-remote-mcp
```

**Via Docker (no Python needed):**
```bash
docker compose up -d   # see docker-compose.yml
```

**From source:**
```bash
git clone https://github.com/ykoellmann/obsidian-mcp.git
cd obsidian-mcp
uv sync
uv run obsidian-remote-mcp
```

## Configuration

Copy `.env.example` to `.env` and set your vault path:

```env
VAULT_PATH=/path/to/your/obsidian/vault
# Optional:
# READ_ONLY=true            # safe default for network Compose deployments
# READ_PATHS=Notes/,Inbox/  # restrict all reads to specific rooted scopes
# WRITE_PATHS=Notes/,Inbox/ # restrict writes to specific folders
# DENY_READ_PATHS=.obsidian/,.trash/ # security boundary for all reads
# DENY_WRITE_PATHS=.obsidian/,.trash/,_AI_INSTRUCTIONS.md
# ALLOW_PERMANENT_DELETE=false
# REQUIRE_WRITE_PRECONDITIONS=true # require read revision before full overwrite
# INDEX_RECONCILE_INTERVAL=900     # full Markdown hash sweep every 15 minutes
# TRANSPORT=stdio           # stdio (default), http (recommended for network use), or sse (legacy)
```

Slash-suffixed policy entries such as `Notes/` cover that directory and its
descendants. An entry without a trailing slash grants or denies only the exact
path. For compatibility, native configuration still permits unrestricted
writes when both `READ_ONLY=false` and `WRITE_PATHS` is empty; choose that
combination explicitly, not as a network default. Use a case-sensitive
filesystem for authoritative path scopes.

> **Docker upgrade note:** this Compose configuration now defaults
> `READ_ONLY=true`, while a native `Config` invocation retains its historical
> `false` default. Existing Docker deployments that intentionally write must
> explicitly set `READ_ONLY=false`; pair it with a narrow `WRITE_PATHS` value.
> For a nested scope such as `deep/nested/`, create `deep/` beforehand. MCP may
> create the configured `nested/` scope and descendants, but never ancestors
> above the configured write boundary.

`READ_PATHS` is an optional allowlist using the same rooted exact/recursive
syntax. When set, direct reads, listings, search, index construction, and
resources are limited to those scopes; `DENY_READ_PATHS` still takes
precedence. Ancestor directories may be traversed only to reach an allowed
scope and do not expose sibling content.

`EXCLUDE_PATHS` uses those same rooted exact/recursive matching rules, but is
only a discovery filter—not an access-control boundary. For example,
`private/` hides that root directory and its descendants, while it does not
hide `Projects/private/`.

Revision-aware note mutation tools must read the target to determine existence,
preserve frontmatter, calculate diffs, or derive an incremental edit. They
therefore reject paths covered by `DENY_READ_PATHS` even if `WRITE_PATHS` also
contains the path. Avoid overlapping those scopes for note workflows. The
storage policy can still support intentionally write-only capabilities that do
not inspect existing content, but `write_note_tool` is not one of them.

The best-effort JSONL audit log is application state, not vault content. It
defaults beneath `LOCK_PATH` for native runs and to `/data/audit.jsonl` in
Docker. `AUDIT_LOG_PATH` must remain outside `VAULT_PATH`; the final log file
is opened without following symlinks.

New files and directories use the normal `0666`/`0777` creation modes filtered
by the MCP process umask. Atomic overwrites preserve the existing file's
permission bits. In a shared sync deployment, run the MCP and sync daemon with
compatible UID/GID and umask settings so both can continue reading and updating
new notes; the home-server profile exposes these as `PUID` and `PGID`.

Direct note reads return an opaque `sha256:...` revision. Pass it as
`expected_revision` when replacing or appending to an existing note so an edit
landed by Obsidian Sync during the client's think time is reported as a conflict
instead of silently overwritten. Network Compose configurations enable strict
full-overwrite preconditions by default. Incremental patch/tag/frontmatter tools
always protect the exact version they read internally. This is optimistic
concurrency, not exactly-once execution: after a lost append response, re-read
and verify the result before retrying without the old revision.

Watcher events are debounced, and the index additionally hashes readable,
indexable Markdown every 15 minutes by default to repair missed events. PDFs,
images, other attachments, excluded paths, and Excalidraw files are not hashed.
See [the design note](docs/implementation/phase-3-sync-concurrency.md) for the
scope, measured cost, health fields, and remaining final-rename race.

Full list of variables — including `API_KEY`, `PUBLIC_BASE_URL`, and the
`OAUTH_GITHUB_*` variables for the optional second auth variant — is
documented with inline comments in `.env.example`; see [Remote Setup](#remote-setup-recommended)
for the two auth variants in detail.

### Optional plugin-format tools (Canvas / Excalidraw / Kanban / Bases)

> [!WARNING]
> **Breaking change:** as of this version, the Canvas, Excalidraw, and Kanban
> tool groups are disabled by default, alongside the new Bases tools. If you
> already rely on any of them, set the matching flag(s) below — otherwise
> those tools disappear from your client's tool list after upgrading.

```env
# ENABLE_CANVAS=true      # .canvas file tools
# ENABLE_EXCALIDRAW=true  # *.excalidraw.md file tools
# ENABLE_KANBAN=true      # Kanban board tools
# ENABLE_BASES=true       # .base file tools (Obsidian core plugin, 1.9.0+)

# High-impact mutations are absent from the MCP tool list unless enabled.
# ENABLE_MOVE=true             # move_note_tool
# ENABLE_FOLDER_RENAME=true    # rename_folder_tool
# ENABLE_BULK_REPLACE=true     # find_replace_in_vault_tool
# ENABLE_DELETE=true           # delete_tool, restore_tool and list_trash_tool
```

Each defaults to `false`. A disabled group's tools aren't just refused at
call time — they're never registered, so they don't appear in the tool list
at all.

The high-impact mutation groups are similarly opt-in: set `ENABLE_MOVE`,
`ENABLE_FOLDER_RENAME`, `ENABLE_BULK_REPLACE`, or `ENABLE_DELETE` to register
the corresponding tools. Their underlying Python functions remain available
for local/unit-test use and future transactional implementations.

## Usage with Claude Code

Add to your MCP config (e.g. `~/.claude/mcp.json`):

```json
{
  "mcpServers": {
    "obsidian": {
      "command": "uv",
      "args": ["--directory", "/path/to/obsidian-mcp", "run", "obsidian-remote-mcp"],
      "env": {
        "VAULT_PATH": "/path/to/your/obsidian/vault"
      }
    }
  }
}
```

## Usage with Claude Desktop

Add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "obsidian": {
      "command": "uv",
      "args": ["--directory", "/path/to/obsidian-mcp", "run", "obsidian-remote-mcp"],
      "env": {
        "VAULT_PATH": "/path/to/your/obsidian/vault"
      }
    }
  }
}
```

## Docker

```bash
# 1. Copy and edit the environment variables
cp .env.example .env

# 2. Set HOST_VAULT_PATH and API_KEY in .env, then:
docker compose up -d
```

The `docker-compose.yml` pulls the pre-built image from GHCR — no cloning or building required. To build locally instead, swap `image:` for `build: .` in the compose file.

GitHub OAuth state is stored under `/data/fastmcp` by default, inside the
Compose `mcp-data` volume, so logins survive container restarts.

The image has a built-in `HEALTHCHECK` against `GET /health` (unauthenticated,
with no vault content or filesystem paths). It reports index readiness plus the
last reconciliation time, duration, and error, and is visible in
`docker ps`/`docker compose ps`. Only meaningful for `TRANSPORT=http`/`sse`; a
no-op for `stdio`.

### Hardened home-server Compose profile

For a home server where Cloudflare Tunnel is the only network entry point,
use [`docker-compose.home-server.yml`](docker-compose.home-server.yml). It
builds the MCP image from the checked-out source, bind-mounts the complete
vault read-only, overlays only the two configured AI memory/output directories
read-write, sets matching `WRITE_PATHS`, runs as the configured non-root
UID/GID, and publishes no host port. The MCP service is only on an internal
network; `cloudflared` has that network plus a separate normal egress network
so it can reach Cloudflare without making MCP externally reachable.

Create the nested writable directories before starting it, set
`HOST_VAULT_PATH`, `AI_MEMORY_PATH`, `AI_OUTPUT_PATH`, `PUID`, `PGID`,
`MCP_DATA_PATH`, `API_KEY`, `CLOUDFLARE_TUNNEL_TOKEN`, and a digest-pinned
`CLOUDFLARED_IMAGE` (for example,
`cloudflare/cloudflared@sha256:<digest>`) in `.env`. Create the data directory
and make it owned by `PUID:PGID`; it stores `/data/locks` and application state.
Cloudflare Access Managed OAuth authenticates the edge, but it does not inject
this application's bearer API key. Clients must still send `API_KEY` to the
origin; trusted-proxy header injection is a future phase, not part of this
profile. Then run:

```bash
docker compose -f docker-compose.home-server.yml up -d
```

Folder/note trash and restore are intentionally unavailable in this nested-bind
profile: moving from a writable overlay into the read-only parent vault's
`.trash` would cross mounts. Keep `ENABLE_DELETE=false` (hard-coded here) and
do not enable folder restore in this topology.

The static Compose checks are covered by the test suite. A real deployment
test (Docker mount precedence, host UID/GID permissions, and the Cloudflare
Tunnel route) remains environment-specific and must be run on the target
server before relying on it. To update Cloudflared, choose a reviewed release,
resolve its immutable `RepoDigest`, update `CLOUDFLARED_IMAGE`, then recreate
the sidecar; rebuild the MCP service after source changes with
`docker compose -f docker-compose.home-server.yml build --pull`.

### Health-Check Cron (Frontmatter Schema)

Separate from the `/health` liveness check above: `scripts/health_check.py` runs `lint_schema_tool`'s logic directly (no MCP client needed) and, only if it finds notes whose frontmatter violates the enums declared in your `_AI_INSTRUCTIONS.md`, drops a report note into your vault's inbox folder. Silent when the vault is clean — no note, no noise.

```bash
# One-off / manual run:
VAULT_PATH=/path/to/vault HEALTH_CHECK_INBOX=00-Inbox python scripts/health_check.py
```

To run it weekly via cron against the running container:

```cron
# crontab -e (on the Docker host)
0 6 * * 1 docker exec obsidian-mcp-obsidian-mcp-1 \
  env VAULT_PATH=/vault READ_ONLY=false WRITE_PATHS=00-Inbox/ \
  HEALTH_CHECK_INBOX=00-Inbox python scripts/health_check.py
```

Swap the container name for whatever `docker compose ps` shows, and set both
`HEALTH_CHECK_INBOX` and `WRITE_PATHS` to your vault's actual inbox folder
(default `Inbox`). The command must have write access because it creates a
report when violations are found. With the home-server profile, choose an
inbox inside one of its writable nested mounts (for example `AI-Output/`).

## Remote Setup (Recommended)

Run obsidian-mcp on a server and connect to it remotely. Network transports
(`sse`/`streamable-http`) need at least one of the two auth variants below —
**API key** and **GitHub OAuth** are independent and can be used at the same
time: keep the API key for Claude Code/Desktop/curl while adding OAuth only
for claude.ai, or use either one alone.

> **Security:** Always put a TLS-terminating reverse proxy (e.g. [Caddy](https://caddyserver.com)) in front when exposing to the internet — required for GitHub OAuth callbacks in particular, since GitHub rejects plain `http://` callback URLs except on `localhost`. `API_KEY`/OAuth are not needed for stdio transport (local use only).

### Option A: API key (Claude Code, Claude Desktop, curl, mcp-remote)

**1. Generate an API key:**
```bash
openssl rand -hex 32
```

**2. Configure the server** (`docker-compose.yml` or `.env`):
```env
VAULT_PATH=/data/vault
TRANSPORT=http
API_KEY=your-generated-key
```
(`sse` also works here, but see the note under Option B — `http` is the
more robust choice and works identically for this bearer-key setup.)

**3. Start:**
```bash
docker compose up -d   # or: uv run obsidian-remote-mcp
```

**4. Connect from your MCP client** (anywhere on the network):
```json
{
  "mcpServers": {
    "obsidian": {
      "type": "http",
      "url": "https://your-server/mcp",
      "headers": {
        "Authorization": "Bearer your-generated-key"
      }
    }
  }
}
```

### Option B: GitHub OAuth (claude.ai Web/Mobile Custom Connector)

claude.ai's "Custom Connector" UI only has fields for OAuth (Authorization
URL, Token URL, Client ID/Secret) — no field for a bearer token or custom
header. obsidian-mcp handles the whole OAuth protocol for you (discovery
endpoints, PKCE, token exchange); you only ever hand claude.ai your server's
URL.

**1. Create a GitHub OAuth App:** GitHub → Settings → Developer settings →
[OAuth Apps](https://github.com/settings/developers) → New OAuth App.
- Homepage URL: your server's public URL (e.g. `https://obsidian.example.com`)
- Authorization callback URL: the same URL + `/auth/callback`
  (e.g. `https://obsidian.example.com/auth/callback`)

Copy the generated **Client ID** and **Client Secret**.

**2. Configure the server** (`docker-compose.yml` or `.env`):
```env
VAULT_PATH=/data/vault
TRANSPORT=http
PUBLIC_BASE_URL=https://obsidian.example.com   # must match the GitHub callback host
OAUTH_GITHUB_CLIENT_ID=your-client-id
OAUTH_GITHUB_CLIENT_SECRET=your-client-secret
OAUTH_GITHUB_ALLOWED_LOGINS=your-github-username # comma-separated; required, no "allow anyone" fallback
```
`OAUTH_GITHUB_ALLOWED_LOGINS` is enforced at login: only the listed GitHub
accounts can authenticate, everyone else is rejected, even with a valid
GitHub account.

> **Use `TRANSPORT=http`, not `sse`.** `sse` caused OAuth authorization
> errors with claude.ai specifically (token issued fine server-side, but
> claude.ai never followed up with a request) — `http` fixed it.

**3. Start:**
```bash
docker compose up -d   # or: uv run obsidian-remote-mcp
```

**4. Connect from claude.ai:** Settings → Connectors → Add Custom Connector,
and enter just the server URL (`https://obsidian.example.com/mcp`). claude.ai
discovers everything else (`/.well-known/oauth-authorization-server`, PKCE,
etc.) automatically and redirects you to GitHub to log in on first connect.

> **Persistence in Docker:** OAuth client registrations and tokens are stored
> under FastMCP's own data directory, which is *not* inside the vault volume
> by default. Without a persistent mount there, every container restart logs
> claude.ai out and forces re-authentication. Set `FASTMCP_HOME` to a mounted
> path (see `docker-compose.yml`) to avoid that.

Keep the vault synced on the server with Syncthing, git+cron, rclone, or
Obsidian Sync. The file watcher picks up normal changes, while periodic
Markdown reconciliation repairs missed watcher events.

## Multi-Vault Setup

By default obsidian-mcp serves one vault (`VAULT_PATH`). If you need several
completely separate vaults from one deployment — e.g. a private vault and a
work vault, each only reachable by its own identity — set `VAULTS_CONFIG` to
the path of a JSON file instead. Vault path-policy settings (`VAULT_PATH`,
`READ_PATHS`, `WRITE_PATHS`, `DENY_READ_PATHS`, `DENY_WRITE_PATHS`, and
`EXCLUDE_PATHS`) and identity settings (`API_KEY` and
`OAUTH_GITHUB_ALLOWED_LOGINS`) then come from that file. See
[`vaults.json.example`](vaults.json.example):

```json
{
  "vaults": {
    "private": {"path": "/vaults/private", "exclude_paths": ["private/", ".obsidian/", ".trash/"]},
    "monari":  {"path": "/vaults/monari",  "write_paths": ["02-Areas/monari/"]}
  },
  "identities": [
    {"type": "api_key",      "value": "sk-...",              "vaults": ["private"]},
    {"type": "github_login", "value": "your-github-username", "vaults": ["private", "monari"], "default": "private"}
  ]
}
```

Each vault entry can set `read_paths`, `write_paths`, `deny_read_paths`,
`deny_write_paths`, `exclude_paths`, and `read_only` independently. Path rules
are rooted: a trailing slash includes descendants, while a rule without one
matches only that exact path. `exclude_paths` controls discovery/indexing;
the read/write/deny fields are the access-control boundary.

```env
VAULT_PATH=              # unused — vaults.json defines paths instead
VAULTS_CONFIG=/data/vaults.json
TRANSPORT=http
```

Multi-vault mode requires an authenticated network transport (`http`, `sse`,
or `streamable-http`). It cannot be used with `stdio`, because stdio has no
authenticated request identity to map to a vault.

Each `identities` entry is either an **API key** (`Authorization: Bearer
<value>`, same as [Option A](#option-a-api-key-claude-code-claude-desktop-curl-mcp-remote)
above) or a **GitHub login** (same allowlist mechanism as
[Option B](#option-b-github-oauth-claudeai-webmobile-custom-connector) — set
`OAUTH_GITHUB_CLIENT_ID`/`SECRET`/`PUBLIC_BASE_URL` as usual, just skip
`OAUTH_GITHUB_ALLOWED_LOGINS` since `vaults.json` replaces it). Both kinds
can be mixed and used at the same time, exactly like today. Whichever
identity a request authenticates as, every tool call is transparently
scoped to that identity's vault(s) — there is no way to reach a vault an
identity isn't listed for.

An identity with more than one entry in `"vaults"` can switch between them:
every tool accepts an optional `vault=<name>` argument for that one call. If
omitted, it uses the identity's configured `"default"`; an identity with
several vaults and no default must pass `vault=` explicitly.
`list_vaults_tool()` returns `[{name, description, is_default}]`
for whichever identity is calling, so an MCP client can discover what it's
allowed to pass — the built-in instructions tell Claude to call it first
and pass `vault=` when the conversation clearly points at a non-default
vault. There's no server-side memory of which vault was picked last; it's
re-selected on every call, same as any other argument.

`/attachments/*` (the direct binary upload/download route) is fully
multi-vault-aware: a plain `Authorization: Bearer` request resolves to that
identity's default vault, or pass `?vault=<name>` in the URL to pick a
different one of its allowed vaults (same rule as the `vault=` tool
argument). `create_attachment_token_tool`'s short-lived scoped tokens
(`?exp=&sig=`) work too, signed against the calling identity's own key and
bound to a specific vault — but only for **api_key** identities, since a
GitHub login has no static secret of its own to sign with; use a plain
`Authorization: Bearer` request for those instead.

> **Known limitation:** `/health` doesn't go through per-request auth/vault
> resolution — it always reports on the first vault listed in
> `vaults.json`, regardless of which identity would be calling. It exposes
> no vault content either way (just process liveness), so this doesn't leak
> anything.

## Vault Conventions (Customization)

Create `_AI_INSTRUCTIONS.md` in your vault root to teach the AI how your specific vault is organized:

```markdown
## Structure
- `Notes/` — evergreen notes and concepts
- `Projects/` — active and archived projects (tag: #project/active, #project/done)
- `Journal/` — daily notes (YYYY-MM-DD.md)

## Frontmatter Schema
- status: active | done | inbox
- tags: nested with / (e.g. #concept/programming)

## Conventions
- Link by stem only, never by full path
- Every note needs a created: date in frontmatter
```

In single-vault mode, the server loads this file at startup and sends it to
the AI as system instructions. Without it, built-in generic Obsidian syntax
guidance is used. Multi-vault servers always use the generic startup
instructions because MCP server instructions are shared by every identity;
use `get_vault_conventions_tool(vault=...)` to load the selected vault's
conventions after authorization. The `_AI_INSTRUCTIONS.md` is the right place
for everything vault-specific — folder layout, tag schema, naming conventions,
and any workflow rules.

## Tool Reference

| Category | Tools |
|---|---|
| **Read** | `list_notes`, `read_note` (mode: full/outline/rendered), `search_notes` |
| **Write** | `write_note`, `patch_note`, `delete`*, `restore`*, `append_to_note`, `patch_frontmatter` (one note or many), `manage_tags`, `move_note`, `find_replace_in_vault` |
| **Folders** | `list_folder`, `create_folder`, `rename_folder`, `list_trash`* (folders are deleted/restored by `delete`*/`restore`*) |
| **Query** | `query_notes`, `get_backlinks`, `get_broken_links`, `get_orphans`, `get_link_graph`, `get_vault_stats`, `get_tasks`, `resolve_alias` |
| **Tags** | `list_all_tags` (mode: flat/tree; notes for one tag: `query_notes(tags=[...])`) |
| **Periodic** | `get_periodic_note` |
| **Canvas** | `list_canvases`, `read_canvas`, `write_canvas`, `patch_canvas` |
| **Excalidraw** | `list_excalidraw`, `read_excalidraw`, `write_excalidraw`, `patch_excalidraw` |
| **Kanban** | `read_kanban`, `create_kanban_board`, `add_kanban_card`, `move_kanban_card`, `delete_kanban_card` |
| **Bases** | `list_bases`, `read_base`, `write_base`, `patch_base` |
| **Attachments** | `list_attachments`, `read_attachment`, `add_attachment` |
| **Templates** | `list_templates`, `create_from_template` |

\* Delete, restore, and trash-listing tools are registered only when `ENABLE_DELETE=true`.

Canvas, Excalidraw, Kanban, and Bases are each opt-in (see [Optional plugin-format tools](#optional-plugin-format-tools-canvas--excalidraw--kanban--bases) above) — their tools only appear once the matching `ENABLE_*` flag is set.

Full parameter documentation is embedded in the server and shown automatically to connected AI clients.

## Architecture

```
src/obsidian_mcp/
├── config.py          # env-based config and read/write security boundaries
├── server.py          # FastMCP entry point, tool and resource registrations
├── domain/
│   ├── models.py      # Note dataclass (frontmatter, tags, wikilinks, tasks, …)
│   ├── parser.py      # Markdown parser (YAML frontmatter, wikilinks, block refs, …)
│   └── index.py       # VaultIndex — alias resolution, backlinks, tag tree, BFS
├── storage/
│   ├── filesystem.py  # VaultStorage authorization and atomic writes
│   ├── locking.py     # hashed locks outside the synced vault
│   └── watcher.py     # watchdog-based vault change detection (polling fallback)
└── tools/
    ├── read.py        # read_note, search_notes, render_note, get_note_outline
    ├── write.py       # write_note, patch_note, move_note, manage_tags, …
    ├── query.py       # graph tools, task aggregation, periodic notes, query_notes
    ├── folders.py     # folder management
    ├── canvas.py      # Obsidian Canvas (.canvas JSON) tools
    ├── excalidraw.py  # Obsidian Excalidraw (*.excalidraw.md) tools
    ├── kanban.py      # Obsidian Kanban plugin tools
    ├── bases.py       # Obsidian Bases (.base YAML) tools
    ├── attachments.py # binary and text attachment handling
    ├── templates.py   # template rendering with variable substitution
    └── prompts.py     # MCP Prompts (weekly_review, daily_note)
```

## Development

```bash
uv run pytest -q       # complete automated suite
uv run ruff check src/ tests/ scripts/run_local_smoke_test.py scripts/smoke_test_mcp.py
```

### Local HTTP functional smoke test

The automated suite exercises most behavior in process. This command also
starts the installed server entry point and connects a real authenticated MCP
client over HTTP:

```bash
uv run python scripts/run_local_smoke_test.py
```

The runner chooses a free localhost port, creates a disposable vault and API
key, starts and health-checks the server, then invokes `smoke_test_mcp.py`. The
client lists tools, creates and reads a uniquely named note, overwrites it,
verifies the bytes on disk, and confirms that a write outside `WRITE_PATHS` is
rejected. The runner always stops the server and removes successful test data;
pass `--keep` to retain the disposable vault and server log for inspection.

Use `smoke_test_mcp.py` directly for an already-running local, containerized,
or remote server. It reads the bearer token from `OBSIDIAN_MCP_API_KEY` (or
prompts securely), so secrets do not need to appear in command history or
process arguments. Its denied-write probe is opt-in via `--denied-note PATH`;
only provide a path known to be outside that server's configured write scope.

## License

MIT
