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 (for
uvx)
Quick start
Add this to your MCP client config (Cursor, Windsurf, Claude Code, etc.):
{
"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:
{
"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:
{
"mcpServers": {
"obsidian-vault": {
"url": "http://localhost:3789/sse"
}
}
}
Running directly
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
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 pathobsidian://tags- all tagsobsidian://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:
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:
- Exact relative-path match (case-insensitive)
- Path-suffix match (if link contains
/) - Basename match with proximity tie-break (deepest shared path prefix with source)
- 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 inSECURITY_REVIEW.md. .obsidian,.git,.trashdenied 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 withVAULT_EXCLUDED_DIRS=external,archiveor with anexcluded_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_pathstill denies only.obsidian,.gitand.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
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
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:
{
"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
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.
Metadata
Release files for some-vault-some-mcp 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| some_vault_some_mcp-0.2.1.tar.gz | 302.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| some_vault_some_mcp-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 381.8 kB
Release files / some_vault_some_mcp-0.2.1.tar.gz
| Download URL | some_vault_some_mcp-0.2.1.tar.gz |
|---|---|
| Size | 302.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
89431e7108f1215423cca293562fe3c4a69800cc716df492ca0f0598532ffb88
|
|
BLAKE2b-256 checksum How to use checksums |
53bc82523dda98ed84493642a15d26e46f304cefd3c1b7a70115ece503b39b0a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency logRelease files / some_vault_some_mcp-0.2.1-py3-none-any.whl
| Download URL | some_vault_some_mcp-0.2.1-py3-none-any.whl |
|---|---|
| Size | 79.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2ad5559a1aac284a4397ce57857ac14e8eadbe8e4d6c5b4924f0f7c851d71854
|
|
BLAKE2b-256 checksum How to use checksums |
9cf62a5421fc877f0b790e6d8a2afd75381b9e651317f6371b88cf5138189beb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency log