Skip to main content
Glama
unnitin

obsidian-mcp

by unnitin
README.md
# obsidian-mcp

Semantic search for your Obsidian vault. Index notes, PDFs, and web pages — then search, read, and write them from Claude and other LLMs via the Model Context Protocol.

---

## What it does

| | |
|---|---|
| **Semantic search** | Find notes by meaning, not just keywords. Ask "what did I write about attention mechanisms?" and get the right notes back. |
| **MCP server** | Claude (and any MCP-compatible LLM) can search your vault, read notes, create and append notes, and index new URLs on your behalf. |
| **iCloud sync** | The vector database is a single SQLite file stored inside your vault — it syncs automatically across all your Macs. |
| **Fully local** | Embeddings run on-device via Apple MPS (Apple Silicon). No API keys, no data leaving your machine. |
| **Auto-reindex** | A file watcher detects changes as you write and incrementally updates the index in the background. |

---

## Architecture

```
┌─────────────────────────────────────────────────────────┐
│                 Obsidian Vault (iCloud)                  │
│  ├── Notes/*.md                                          │
│  └── .obsidian-search/semantic-search.db  ← vectors      │
└─────────────────────────────────────────────────────────┘
                    ▲ reads / writes
                    │
┌───────────────────┴─────────────────────────────────────┐
│             Python Backend  (local process)              │
│  FastMCP  stdio   ◄─── Claude Desktop / LLMs             │
│  FastAPI  :51234  ◄─── local scripts (optional)          │
│                                                          │
│  bge-small-en-v1.5  (sentence-transformers, CPU)         │
│  file watcher — incremental reindex on save              │
└──────────────────────────────────────────────────────────┘

Obsidian edits notes on disk; the watcher picks the changes up. Nothing is
installed into Obsidian itself.
```

See [`docs/userflows.md`](docs/userflows.md) for detailed Mermaid diagrams of every interaction path.

---

## Features

### Indexing

- **Markdown notes** — header-hierarchy chunking with YAML frontmatter stored as metadata; Obsidian tags used for filtering
- **Tables** — kept as atomic chunks; oversized tables split on row boundaries with header repeated
- **Mermaid diagrams** — DSL text indexed as-is with surrounding context
- **Figure embeds** (`![[image.png]]`) — surrounding paragraph and caption indexed
- **Callout blocks** (`> [!note]`) — atomic chunks with callout type in metadata
- **PDFs** — converted to structured Markdown via `pymupdf4llm` (preserves tables, columns, infers headings)
- **Web pages** — fetched with `httpx`, cleaned with `trafilatura`, chunked the same way as Markdown

### Search

- Query embedding → ANN search (top-50 candidates) → CrossEncoder rerank → top K results
- Filter by source type (`markdown`, `pdf`, `web`) or Obsidian frontmatter tags
- ~50–120 ms end-to-end on Apple Silicon

### Vector storage

- `sqlite-vec` — single `.db` file, no companion WAL/SHM files, safe for iCloud sync
- Stored at `{vault}/.obsidian-search/semantic-search.db`
- Incremental updates: mtime-based deduplication skips unchanged chunks

---

## Project structure

```
obsidian-mcp/
├── packages/
│   └── backend/                  # Python — FastMCP + FastAPI server
│       └── src/obsidian_search/
│           ├── config.py         # pydantic-settings (VAULT_PATH, port, …)
│           ├── models.py         # Chunk, SearchResult, WriteResult, …
│           ├── ingestion/        # chunker_markdown, chunker_pdf, chunker_web
│           ├── embedding/        # embedding model singleton
│           ├── store/            # sqlite-vec CRUD + ANN search
│           ├── search/           # searcher + optional cross-encoder reranker
│           ├── vault/            # note writes (create / append)
│           ├── watcher/          # watchdog FSEventsObserver
│           ├── api/              # FastAPI routes (/search, /ingest/*, /status)
│           └── mcp/              # FastMCP tools for Claude
├── docs/
│   ├── userflows.md              # Mermaid diagrams for all user flows
│   └── branch-protection.md      # GitHub branch protection setup guide
├── .github/workflows/ci.yml      # Lint + typecheck + tests gate
└── PLAN.md                       # Architecture decisions and implementation plan
```

---

## Prerequisites

- macOS (Apple Silicon recommended)
- [uv](https://docs.astral.sh/uv/) — Python package manager
- Obsidian desktop app (to edit the vault; no plugin required)

---

## Setup

### 1. Install the backend

```bash
git clone https://github.com/unnitin/obsidian-mcp.git
cd obsidian-mcp
uv sync --all-extras
```

This creates `.venv/` and installs all Python dependencies including the embedding model runtime.

### 2. Configure your vault path

```bash
cp .env.example .env
# Edit .env and set VAULT_PATH to the absolute path of your Obsidian vault
```

**If your vault is in iCloud** (the default for Obsidian on macOS), the path contains a space. Find it with:

```bash
ls "$HOME/Library/Mobile Documents/iCloud~md~obsidian/Documents/"
```

Then set:

```dotenv
VAULT_PATH=/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/YourVaultName
```

The backend reads this folder directly from your local iCloud Drive cache — macOS keeps it in sync automatically. No special iCloud configuration is needed.

### 3. Start the backend

The backend is a **local Python process** that runs on the same Mac as your vault (or a Mac mini on your local network). It is not a cloud service.

```bash
./scripts/start-backend.sh
```

This starts:
- **FastAPI server** on `http://127.0.0.1:51234` for local scripts (optional — the MCP server runs standalone)
- **File watcher** monitoring your vault for changes and updating the index incrementally

> Run either the API server or the MCP server, not both against the same vault —
> each starts its own file watcher and writes to the same index.

### Running on a Mac mini (always-on server)

A Mac mini makes an ideal always-on host for this server. The backend process runs **on the Mac mini**, reads the vault from the Mac mini's local iCloud Drive folder (which macOS keeps in sync), and exposes the search API over your local network. Nothing leaves your home network.

#### iCloud vault path

Obsidian iCloud vaults are stored in a macOS-managed folder with a space in the path. Find yours:

```bash
ls "$HOME/Library/Mobile Documents/iCloud~md~obsidian/Documents/"
```

Your vault path will be:

```
/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/YourVaultName
```

Always wrap this path in double quotes in shell commands.

#### Prevent the Mac mini from sleeping

The server process stops if the machine sleeps. Open **System Settings → Energy → Power Adapter** and set:

- **"Prevent automatic sleeping when the display is off"** → On
- **"Wake for network access"** → On (optional, for Wake-on-LAN)

Or apply the setting from the terminal:

```bash
sudo pmset -c sleep 0 disksleep 0
```

#### Auto-start with launchd

Create `~/Library/LaunchAgents/com.obsidian-search.backend.plist`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.obsidian-search.backend</string>

  <key>ProgramArguments</key>
  <array>
    <string>/Users/yourname/.local/bin/uv</string>
    <string>run</string>
    <string>--project</string>
    <string>/Users/yourname/obsidian-mcp/packages/backend</string>
    <string>obsidian-search-api</string>
  </array>

  <key>EnvironmentVariables</key>
  <dict>
    <key>VAULT_PATH</key>
    <string>/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/YourVaultName</string>
    <key>HOME</key>
    <string>/Users/yourname</string>
    <key>OBSIDIAN_SEARCH_HOST</key>
    <string>0.0.0.0</string>
    <!-- Required: listening beyond loopback without a token would expose note
         contents and file indexing to the whole network, so the server exits
         at startup if this is missing. Generate one with:
         python -c 'import secrets; print(secrets.token_urlsafe(32))' -->
    <key>OBSIDIAN_SEARCH_API_TOKEN</key>
    <string>paste-your-generated-token-here</string>
  </dict>

  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>

  <key>StandardOutPath</key>
  <string>/tmp/obsidian-search.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/obsidian-search.err</string>
</dict>
</plist>
```

Replace `yourname` and the vault name, then load it:

```bash
launchctl load ~/Library/LaunchAgents/com.obsidian-search.backend.plist
```

Check it started:

```bash
launchctl list | grep obsidian-search
curl http://localhost:51234/health
tail -f /tmp/obsidian-search.log
```

#### Access from other Macs on your network

Setting `OBSIDIAN_SEARCH_HOST=0.0.0.0` (shown in the plist above) makes the server listen on all interfaces. **This requires an API token** — the server refuses to start on a non-loopback host without one, because the API can read note contents and index files:

```bash
python -c 'import secrets; print(secrets.token_urlsafe(32))'
# put the result in OBSIDIAN_SEARCH_API_TOKEN, then send it on every request:
curl -H "Authorization: Bearer $TOKEN" http://192.168.1.42:51234/status
```

You also need to allow the port through the macOS firewall:

1. Open **System Settings → Network → Firewall → Options**
2. Click **+**, navigate to `/Users/yourname/.venv/bin/uvicorn`, and set it to **Allow incoming connections**

On your other Mac, use the Mac mini's local IP instead of `127.0.0.1`:

```bash
# Find the Mac mini's IP
# On the Mac mini:
ipconfig getifaddr en0

# Reach it from your other Mac at:
# http://192.168.x.x:51234
```

Point local clients at `http://192.168.x.x:51234` instead of `127.0.0.1`.

---

### 4. Connect Claude Desktop

Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "obsidian-search": {
      "command": "uv",
      "args": [
        "run",
        "--project", "/path/to/obsidian-mcp/packages/backend",
        "python", "-m", "obsidian_search.mcp.server"
      ],
      "env": {
        "VAULT_PATH": "/path/to/your/vault"
      }
    }
  }
}
```

Restart Claude Desktop. You'll see the 🔌 icon indicating the MCP server is connected.

---

## Usage

### Claude

Once connected, Claude can:

```
"What did I write about the CAP theorem?"
"Summarise my notes on async Rust"
"Index this article for me: https://..."
"Start a note in Projects/ for the migration plan"
"Add today's standup notes to my weekly log"
"How many documents are in my vault index?"
```

Available MCP tools:

| Tool | Description |
|------|-------------|
| `search_notes` | Semantic search with optional type/tag filters |
| `get_note_content` | Read a full note by vault-relative path |
| `create_note` | Create a new markdown note and index it (never overwrites) |
| `append_to_note` | Append to an existing note and reindex it |
| `index_url` | Fetch, chunk, and index a URL |
| `index_pdf` | Index a PDF inside the vault |
| `index_note` | Force a re-index of a single markdown note |
| `get_index_status` | Total chunks, documents, last indexed time |
| `list_indexed_files` | All indexed documents with chunk counts |
| `remove_from_index` | Remove a document from the index (leaves the file on disk) |

Every path argument is confined to the vault; writes are additive only — no
tool can overwrite, move, or delete a note.

---

## Development

### Run tests

```bash
uv run pytest packages/backend/tests/ -v
```

### Run tests with coverage

```bash
uv run pytest packages/backend/tests/ --cov=packages/backend/src --cov-report=term-missing
```

### Lint and format

```bash
uv run ruff check packages/backend/
uv run ruff format packages/backend/
```

### Type check

```bash
uv run mypy packages/backend/src/
```

Pre-commit hooks run ruff and mypy automatically on every commit.

### CI

GitHub Actions runs lint, typecheck, and tests on every PR to `main`. A PR cannot be merged unless the `All checks passed` gate job succeeds. See [`docs/branch-protection.md`](docs/branch-protection.md) for setup instructions.

---

## Configuration reference

All settings can be set via environment variables or a `.env` file in the project root.

| Variable | Default | Description |
|----------|---------|-------------|
| `VAULT_PATH` | *(required)* | Absolute path to your Obsidian vault |
| `OBSIDIAN_SEARCH_PORT` | `51234` | FastAPI server port |
| `OBSIDIAN_SEARCH_HOST` | `127.0.0.1` | FastAPI server host |
| `OBSIDIAN_SEARCH_API_TOKEN` | *(none)* | Bearer token required on every route except `/health`. Mandatory when `HOST` is not loopback |
| `OBSIDIAN_SEARCH_ALLOW_PRIVATE_URLS` | `false` | Allow `/ingest/url` to fetch private/loopback addresses |
| `OBSIDIAN_SEARCH_EMBEDDING_MODEL` | `BAAI/bge-small-en-v1.5` | HuggingFace model ID |
| `OBSIDIAN_SEARCH_DEVICE` | `cpu` | Torch device for the embedder and reranker. MPS costs ~1 GB of address space per model |
| `OBSIDIAN_SEARCH_DEFAULT_TOP_K` | `10` | Result count when a caller omits `top_k` |
| `OBSIDIAN_SEARCH_RERANK_CANDIDATES` | `50` | ANN candidates fetched before filtering and optional reranking |
| `OBSIDIAN_SEARCH_CHUNK_MAX_TOKENS` | `512` | Maximum tokens per chunk |
| `OBSIDIAN_SEARCH_CHUNK_MIN_TOKENS` | `64` | Minimum tokens before merging |
| `OBSIDIAN_SEARCH_EXCLUDED_FOLDERS` | `[]` | JSON array of folder names to skip |
| `OBSIDIAN_SEARCH_WATCHER_DEBOUNCE_SECONDS` | `2.0` | Debounce delay for file watcher |

---

## Tech stack

| Layer | Technology |
|-------|-----------|
| Embeddings | `sentence-transformers` — bge-small-en-v1.5 (384d), CPU by default |
| Reranking | `sentence-transformers` CrossEncoder — ms-marco-MiniLM-L-6-v2 |
| Vector store | `sqlite-vec` — single-file, iCloud-safe |
| PDF parsing | `pymupdf4llm` |
| Web extraction | `trafilatura` + `httpx` |
| Markdown parsing | hand-rolled header/block chunker + `python-frontmatter` for YAML |
| API server | `fastapi` + `uvicorn` |
| MCP server | `fastmcp` (stdio transport) |
| File watcher | `watchdog` (FSEvents on macOS) |
| Package manager | `uv` |