some-vault-some-mcp
# some-vault-some-mcp
MCP server that gives AI models read/write access to Obsidian vaults with semantic search, link graph analysis, canvas manipulation, and incremental indexing.
Built on FastMCP + LanceDB. Embeds notes locally with fastembed (nomic-embed-text-v1.5-Q, 768 dims) by default — no server required. Optionally supports Ollama or OpenAI embeddings. Watches the vault filesystem and re-indexes on change.
Warning: This is a hot vibe coded mess, user beware
## What it does
- Hybrid search - vector similarity (70%) + full-text keyword (30%), with overlap boost
- Semantic search - pure embedding-based retrieval
- Exact search - literal substring matching, optional case sensitivity and regex
- Note CRUD - create, read, append, prepend, move, delete (soft delete to .trash by default)
- Frontmatter parsing - YAML extraction, tag collection (frontmatter + inline #hashtags), property filtering
- Wikilink graph - backlinks, outlinks, orphan detection, broken link detection, BFS neighbor traversal (depth 1-5)
- Canvas CRUD - create, read, add/update/remove nodes and edges, grid auto-layout, dangling edge cleanup
- Daily notes - Moment.js-style date formatting, template support, reads Obsidian's daily-notes config
- Incremental indexing - filesystem watcher with 2s debounce and SHA-256 content change detection
- Atomic writes - temp file + POSIX rename, per-path asyncio locks
- Tool overrides - rename or disable any tool via YAML config (per-agent customization)
## Requirements
- Python 3.11+
- [uv](https://docs.astral.sh/uv/) (for `uvx`)
## Quick start
Add this to your MCP client config (Cursor, Windsurf, Claude Code, etc.):
```json
{
"mcpServers": {
"obsidian-vault": {
"command": "uvx",
"args": ["some-vault-some-mcp", "serve", "--transport", "stdio"],
"env": {
"VAULT_PATH": "/path/to/your/obsidian/vault"
}
}
}
}
```
That's it. `uvx` installs the package from PyPI on first run and keeps it cached.
For alternative embedding providers, add `--from` with extras:
```json
{
"command": "uvx",
"args": ["--from", "some-vault-some-mcp[ollama]", "some-vault-some-mcp", "serve", "--transport", "stdio"],
"env": {
"VAULT_PATH": "/path/to/your/obsidian/vault"
}
}
```
Replace `[ollama]` with `[openai]` or `[gpu]` as needed.
### SSE mode
If running the server separately (Docker, remote, etc.), use the SSE URL instead:
```json
{
"mcpServers": {
"obsidian-vault": {
"url": "http://localhost:3789/sse"
}
}
}
```
### Running directly
```bash
VAULT_PATH=/path/to/vault uvx some-vault-some-mcp serve
```
First run builds a staged index generation and publishes it only after schema,
vector, and full-text validation. Subsequent starts scan content hashes and
publish incremental changes through staged generations too, so the last valid
generation remains available on failure. Readers hold a cross-process shared
lease while materializing results; publication and live cleanup take the
exclusive side, so completed databases retain only the active and previous
generations without dropping an in-flight reader's table.
### CLI flags
```
some-vault-some-mcp serve [--transport sse|stdio] [--host 127.0.0.1] [--port 3789] [--reindex-force]
```
`--reindex-force` builds and validates a replacement generation without dropping
the active index first. It is required when embedding dimensions change. The server
also rebuilds automatically (one time) when it detects an index written by an
older storage format.
CLI flags override env vars.
## Environment variables
| Variable | Default | Notes |
|---|---|---|
| `VAULT_PATH` | (required) | Absolute path to Obsidian vault |
| `LANCE_DB_PATH` | `./data/vault.lance` | Vector index location. Use a trusted absolute path outside the vault and workspace; unsafe locations produce a warning. |
| `MCP_TRANSPORT` | `sse` | `sse` or `stdio` |
| `MCP_HOST` | `127.0.0.1` | SSE bind address. Defaults to loopback — set `0.0.0.0` to expose on the network (the Docker image does this explicitly). Binding non-loopback without `VAULT_API_KEY` logs a warning. |
| `MCP_PORT` | `3789` | SSE port |
| `EMBEDDING_PROVIDER` | `fastembed` | `fastembed`, `ollama`, `openai`, or `mock` |
| `FASTEMBED_MODEL` | `nomic-ai/nomic-embed-text-v1.5-Q` | Any fastembed-supported model. On Apple Silicon, auto-detects and uses the non-quantized variant (`v1.5` instead of `v1.5-Q`) since the quantized ONNX ops are x86-optimized |
| `FASTEMBED_DIMENSIONS` | (auto-detected) | Override dimension auto-detection |
| `OLLAMA_URL` | `http://localhost:11434` | Ollama API endpoint (requires `--extra ollama`) |
| `OPENAI_API_KEY` | | Required if provider is `openai` (requires `--extra openai`) |
| `VAULT_API_KEY` | | Enables Bearer token auth on SSE transport (constant-time compare; covers `/sse` and websocket connections) |
| `VAULT_ALLOW_UNAUTH_SSE` | `false` | `true` exempts the `/sse` GET from auth — only for clients that cannot send an `Authorization` header on the SSE stream (Cursor/Claude Code/Claude Desktop all can, so leave `false`) |
| `VAULT_SOFT_DELETE_IS_PERMANENT` | `false` | `true` makes delete_note do hard deletes |
| `some_vault_some_mcp_OVERRIDES` | | Path to YAML override file |
## Docker
```bash
docker build -t some-vault-some-mcp .
docker run -p 3789:3789 \
-v /path/to/vault:/opt/vault:ro \
-v /data:/opt/data \
some-vault-some-mcp
```
Runs as non-root user (vault:vault, UID 1000). Index data persists in `/opt/data`. Vault mounted read-only. Container is self-contained — fastembed runs in-process, no Ollama sidecar needed.
To use Ollama instead: `docker run ... -e EMBEDDING_PROVIDER=ollama -e OLLAMA_URL=http://ollama:11434 some-vault-some-mcp`
## Tools (27)
### Search
#### `search`
Find notes by text, meaning, or exact string.
| Param | Type | Default | Notes |
|---|---|---|---|
| `query` | string | (required) | Search query text |
| `mode` | string | `"hybrid"` | `hybrid`, `semantic`, or `exact` |
| `top_k` | int | `10` | Max results to return |
| `tags` | string[] | `null` | Pre-filter by tags |
| `folder` | string | `null` | Pre-filter by folder path prefix |
| `case_sensitive` | bool | `false` | Exact mode only - match case |
### Read
#### `get_note`
Read a single note with parsed frontmatter and tags.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Vault-relative path to the note. Extension-agnostic — `todo` and `todo.md` both resolve to `todo.md`. A bare basename resolves Obsidian-style to a matching note anywhere in the vault. |
#### `list_notes`
Enumerate vault notes with optional metadata filters. Index-backed fields (tags, projects, status, area) query LanceDB when available. Frontmatter property filtering scans files directly.
| Param | Type | Default | Notes |
|---|---|---|---|
| `folder` | string | `null` | Filter by folder path prefix |
| `tags` | string[] | `null` | Filter by tags (index-backed) |
| `projects` | string[] | `null` | Filter by projects (index-backed) |
| `status` | string | `null` | Filter by status field (index-backed) |
| `area` | string | `null` | Filter by area field (index-backed) |
| `frontmatter_property` | string | `null` | Arbitrary frontmatter key to filter on |
| `frontmatter_value` | string | `null` | Value to match for `frontmatter_property` (case-insensitive) |
| `include_content` | bool | `false` | Include note content in results |
| `limit` | int | `50` | Max results to return |
### Write
#### `create_note`
Create a new note. Fails if it already exists.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Vault-relative path for the new note |
| `content` | string | (required) | Note body text |
| `frontmatter` | string | `null` | JSON string of frontmatter fields, e.g. `'{"title":"My Note","tags":["idea"]}'` |
#### `append_to_note`
Append text to the end of an existing note.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Vault-relative path |
| `content` | string | (required) | Text to append |
#### `prepend_to_note`
Insert text after frontmatter, before the note body.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Vault-relative path |
| `content` | string | (required) | Text to prepend |
#### `update_frontmatter`
Merge key-value pairs into YAML frontmatter. Unlisted keys preserved.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Vault-relative path |
| `properties` | string | (required) | JSON string of key-value pairs, e.g. `'{"status":"done","tags":["review"]}'` |
#### `move_note`
Move or rename a note. Rewrites wikilinks vault-wide by default. Moves are
no-replace operations: an existing distinct destination is never overwritten.
Filesystems without the platform's native exclusive-rename capability are
rejected rather than falling back to an overwriting rename. A separately named
hard-link destination also fails closed because it cannot be removed safely
under a concurrent destination replacement.
| Param | Type | Default | Notes |
|---|---|---|---|
| `old_path` | string | (required) | Current vault-relative path |
| `new_path` | string | (required) | Destination vault-relative path |
| `update_links` | bool | `true` | Rewrite wikilinks in all referencing notes |
#### `delete_note`
Delete a note. Moves to .trash by default; use permanent for hard delete.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Vault-relative path |
| `permanent` | bool | `false` | `true` for hard delete, `false` for soft delete to .trash |
### Daily notes
#### `get_daily_note`
Read today's or a specific date's daily note. Uses Obsidian's daily-notes config for folder and date format. Returns a text message (not an error) if no note exists for the requested date.
| Param | Type | Default | Notes |
|---|---|---|---|
| `date` | string | `null` | Date string (e.g. `"2024-03-15"`). Omit for today |
#### `create_daily_note`
Create a daily note for today or a given date. Fails if one exists. Supports `{{date}}` placeholder in templates.
| Param | Type | Default | Notes |
|---|---|---|---|
| `date` | string | `null` | Date string. Omit for today |
| `content` | string | `null` | Note body text |
| `template_path` | string | `null` | Vault-relative path to a template note |
### Tags
#### `get_tags`
List all unique tags in the vault with per-note usage counts.
| Param | Type | Default | Notes |
|---|---|---|---|
| `sort_by` | string | `"count"` | `count` (descending) or `name` (alphabetical) |
### Link graph
#### `get_backlinks`
Find notes that link to a given note.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Vault-relative path of the target note |
#### `get_outlinks`
List all outgoing wikilinks from a note. Shows valid, broken, and embed links separately.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Vault-relative path |
#### `find_orphans`
Find disconnected notes - no inbound links, no outbound links, or both.
| Param | Type | Default | Notes |
|---|---|---|---|
| `include_outlinks_check` | bool | `true` | Also report notes with no outgoing links |
| `max_results` | int | `200` | Cap per category |
#### `find_broken_links`
Find wikilinks pointing at notes that don't exist.
| Param | Type | Default | Notes |
|---|---|---|---|
| `folder` | string | `null` | Limit scan to a folder |
| `max_results` | int | `200` | Max broken links to return |
#### `get_graph_neighbors`
Walk the link graph outward from a note via BFS.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Starting note path |
| `depth` | int | `1` | BFS depth, 1-5 |
| `direction` | string | `"both"` | `inbound`, `outbound`, or `both` |
### Canvas
#### `list_canvases`
List all .canvas files in the vault.
| Param | Type | Default | Notes |
|---|---|---|---|
| `folder` | string | `null` | Filter by folder path prefix |
#### `read_canvas`
Read canvas structure (nodes + edges).
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Vault-relative path to .canvas file |
#### `create_canvas`
Create a new .canvas file with optional initial nodes/edges. Fails if it already exists.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | (required) | Vault-relative path for the new canvas |
| `nodes` | string | `null` | JSON array of node objects, e.g. `'[{"type":"text","text":"Hello"}]'` |
| `edges` | string | `null` | JSON array of edge objects |
#### `add_canvas_node`
Add a node (text/file/link/group) to a canvas. Position auto-computed via grid layout if omitted.
| Param | Type | Default | Notes |
|---|---|---|---|
| `canvas_path` | string | (required) | Path to the canvas |
| `node_type` | string | (required) | `text`, `file`, `link`, or `group` |
| `text` | string | `null` | Text content (for text nodes) |
| `file` | string | `null` | Vault-relative file path (for file nodes). For markdown notes the `.md` extension is optional and a bare name resolves Obsidian-style; the full path is stored. Attachments (images/PDFs) must be given with their extension. |
| `url` | string | `null` | URL (for link nodes) |
| `label` | string | `null` | Display label |
| `x` | int | `null` | X position. Auto-placed if omitted |
| `y` | int | `null` | Y position. Auto-placed if omitted |
| `width` | int | `250` | Node width in pixels |
| `height` | int | `60` | Node height in pixels |
| `color` | string | `null` | Obsidian preset `"1"`-`"6"` (red, orange, yellow, green, cyan, purple) or hex `"#FF0000"` |
#### `update_canvas_node`
Update any property of an existing canvas node by ID. Only provided fields are changed.
| Param | Type | Default | Notes |
|---|---|---|---|
| `canvas_path` | string | (required) | Path to the canvas |
| `node_id` | string | (required) | ID of the node to update |
| `x` | int | `null` | New X position |
| `y` | int | `null` | New Y position |
| `width` | int | `null` | New width |
| `height` | int | `null` | New height |
| `color` | string | `null` | Preset `"1"`-`"6"` or hex `"#FF0000"` |
| `text` | string | `null` | New text content |
| `file` | string | `null` | New file reference |
| `url` | string | `null` | New URL |
| `label` | string | `null` | New label |
#### `remove_canvas_nodes`
Remove nodes by ID. Auto-removes dangling edges.
| Param | Type | Default | Notes |
|---|---|---|---|
| `canvas_path` | string | (required) | Path to the canvas |
| `node_ids` | string | (required) | JSON array of node ID strings, e.g. `'["id1","id2"]'` |
#### `add_canvas_edge`
Add an edge between two canvas nodes with full property support.
| Param | Type | Default | Notes |
|---|---|---|---|
| `canvas_path` | string | (required) | Path to the canvas |
| `from_node` | string | (required) | Source node ID |
| `to_node` | string | (required) | Target node ID |
| `from_side` | string | `null` | Anchor side on source: `top`, `bottom`, `left`, `right` |
| `to_side` | string | `null` | Anchor side on target |
| `from_end` | string | `null` | End style on source side (e.g. `arrow`, `none`) |
| `to_end` | string | `null` | End style on target side |
| `color` | string | `null` | Preset `"1"`-`"6"` or hex `"#FF0000"` |
| `label` | string | `null` | Edge label text |
#### `update_canvas_edge`
Update properties of an existing canvas edge by ID. Only provided fields are changed.
| Param | Type | Default | Notes |
|---|---|---|---|
| `canvas_path` | string | (required) | Path to the canvas |
| `edge_id` | string | (required) | ID of the edge to update |
| `from_side` | string | `null` | New source anchor side |
| `to_side` | string | `null` | New target anchor side |
| `from_end` | string | `null` | New source end style |
| `to_end` | string | `null` | New target end style |
| `color` | string | `null` | Preset `"1"`-`"6"` or hex `"#FF0000"` |
| `label` | string | `null` | New label |
#### `remove_canvas_edges`
Remove edges from a canvas by ID.
| Param | Type | Default | Notes |
|---|---|---|---|
| `canvas_path` | string | (required) | Path to the canvas |
| `edge_ids` | string | (required) | JSON array of edge ID strings |
### Index management
#### `vault_index_status`
Check search index health and statistics, including rebuild progress, degraded
fallback serving, pending changes, and the last rebuild error. No arguments.
#### `vault_reindex`
Reindex one note incrementally, or build and atomically publish a new generation
when the path is omitted.
| Param | Type | Default | Notes |
|---|---|---|---|
| `path` | string | `null` | Vault-relative path to reindex a single note. Omit for full vault |
## MCP resources
- `obsidian://note/{path}` - read a note by path
- `obsidian://tags` - all tags
- `obsidian://daily` - today's daily note
## Tool name overrides
When you need to change the tool names and descriotions, a YAML file can be used. Create a YAML file, and set `some_vault_some_mcp_OVERRIDES` to its path:
```yaml
tools:
search:
name: "vault_search"
description: "Custom description for this agent"
get_note:
name: "read_note"
disabled:
- get_tags
- find_orphans
```
Overrides apply at registration time. Useful for per-agent tool namespacing or disabling tools an agent shouldn't use if your agent doesn't support tool disabling.
## How search works
**Hybrid** (default) - runs both semantic and FTS (LanceDB full-text search, BM25-style) at 2x requested top_k, normalizes scores, combines with `semantic * 0.7 + FTS * 0.3`. Results appearing in both get a 1.2x boost. Returns top_k from merged set.
**Semantic** - embeds the query, runs vector similarity search against LanceDB.
**Exact** - scans raw vault files for literal substring matches. Not index-backed. Optional case sensitivity.
All search modes accept tag and folder pre-filters. Tags use SQL LIKE against comma-separated tag strings in the index. Folders use path prefix matching.
## Chunking
Splits markdown by heading hierarchy first, then by paragraph for oversized sections. Tracks heading breadcrumbs through the split - a chunk under `# A / ## B / ### C` gets heading field `"A > B > C"`. Metadata header prepended to embedding text: `[Title: X | Section: A > B | Tags: foo, bar]`.
## Wikilink resolution
Matches Obsidian's behavior in 4 steps:
1. Exact relative-path match (case-insensitive)
2. Path-suffix match (if link contains `/`)
3. Basename match with proximity tie-break (deepest shared path prefix with source)
4. Alias match (frontmatter aliases)
First matching step wins.
## Security boundaries
- Tool paths are validated against the vault root and reject `../`, null bytes,
and static symlink escapes. These pathname checks do not close the concurrent
symlink-swap race described as SR-04 in `SECURITY_REVIEW.md`.
- `.obsidian`, `.git`, `.trash` denied at the tool boundary and excluded from indexing
- Any folder whose name starts with `.` (for example `.claude/`) is skipped by the indexer and by note listings. Add more folder names with `VAULT_EXCLUDED_DIRS=external,archive` or with an `excluded_dirs: [external, archive]` list in the override file. These folders are hidden, not denied: search, listings, backlinks and link rewrites skip them, but a tool call with the exact path still works. `resolve_vault_path` still denies only `.obsidian`, `.git` and `.trash`.
- Optional Bearer token auth on SSE transport
- Docker container runs as non-root
- Frontmatter normalization rejects recursive aliases, excessive depth, excessive
node or scalar-payload expansion, invalid timestamps, non-string mapping keys,
and unsupported scalar types. Invalid metadata is discarded while the note
body remains indexable.
### Index recovery
Full rebuilds retain the previous valid generation. If a newly published index
must be rolled back, stop every server process using that `LANCE_DB_PATH`, back up
the complete index directory, then swap the `active` and `previous` table names in
`_index_manifest.json`. Write the edited JSON to a sibling temporary file and
atomically replace the manifest; do not edit it in place. Restart the server and
confirm `vault_index_status` reports the expected file and chunk counts. Keep the
backup until search results have been checked. If either named table is missing or
the manifest is malformed, restore the backup instead of deleting tables manually.
## Developing
### Setup
```bash
git clone https://github.com/russellsch/some_obsidian_some_mcp.git
cd some_obsidian_some_mcp
uv sync # default (fastembed CPU)
uv sync --extra gpu # GPU-accelerated embeddings
uv sync --extra ollama # Ollama provider
uv sync --extra openai # OpenAI provider
```
### Running from source
```bash
VAULT_PATH=/path/to/vault uv run some-vault-some-mcp serve
```
To use a local checkout in your MCP client config instead of the PyPI package:
```json
{
"mcpServers": {
"obsidian-vault": {
"command": "uv",
"args": ["run", "--project", "/path/to/some_obsidian_some_mcp", "some-vault-some-mcp", "serve", "--transport", "stdio"],
"env": {
"VAULT_PATH": "/path/to/your/obsidian/vault"
}
}
}
}
```
### Tests
```bash
uv run pytest tests/unit # unit tests (fast, no external deps)
uv run pytest tests/integration # integration tests (real LanceDB, may download ~130MB model)
uv run pytest # everything
```
Test fixtures live in `tests/fixtures/vault/`.
### Releasing
See [RELEASING.md](RELEASING.md).
TDQS
Scored across 28 tools
Most tools target a distinct resource and action, but a few could be confused: search overlaps with list_notes for finding content, and get_graph_neighbors could be mistaken for a combined version of get_backlinks/get_outlinks. Overall, the descriptions are clear enough to separate the graph, note, and canvas operations.
The dominant verb_noun pattern is consistent (create_note, move_note, delete_note, add_canvas_node, etc.), with minor deviations like bare 'search', 'vault_index_status' (noun_status), and singular/plural mismatches in canvas operations (update_canvas_node vs remove_canvas_nodes). These are readable and predictable.
28 tools is on the heavy side for an MCP server, though the breadth is justified by covering notes, daily notes, link analysis, indexing, and canvases. It exceeds the typical well-scoped range and feels dense, but not bloated with near-duplicate functionality.
The note lifecycle is well-covered (create, read, update via append/prepend/frontmatter, move, delete), and link analysis plus canvas CRUD are thorough. Minor gaps exist: there is no in-place body editing or trash restore tool, and daily notes can only be created/read, not modified directly.