A TypeScript MCP server that gives Claude instant graph navigation over an Obsidian vault. It parses all wiki-links, builds a SQLite index, and exposes query tools that Claude can call mid-conversation.
Claude navigates an Obsidian vault badly without this. Every graph traversal is manual breadth-first search — one tool call at a time. "What files link to Kim.md?" requires a Grep across the whole vault. With this index, it's a single MCP tool call returning results in ~200 tokens instead of 4K+.
Three source files, clean separation:
tools/entity-index/
├── src/
│ ├── index.ts # MCP server — tool definitions + freshness checks
│ ├── parser.ts # Vault scanner — walks .md files, extracts links
│ └── database.ts # SQLite index — schema, rebuild, query methods
├── package.json
└── tsconfig.json
- Walks every
.mdfile in the vault (skipping.obsidian,node_modules,tools,dist) - Extracts from each file: frontmatter (
type,tags), first#heading as title, and all[[wiki-links]]with line numbers - Resolves link targets using Obsidian's rules:
- If the link contains a
/, treat it as a path from vault root - Otherwise, do a filename-stem lookup (case-insensitive)
- This matches how Obsidian itself resolves
[[Kim]]→06-people/Kim.md
- If the link contains a
- The wiki-link regex handles all Obsidian formats:
[[target]],[[target|alias]],[[target#heading]],[[target#heading|alias]]
Two tables:
CREATE TABLE files (
id INTEGER PRIMARY KEY,
path TEXT UNIQUE NOT NULL,
title TEXT,
type TEXT,
tags TEXT,
last_modified INTEGER NOT NULL
);
CREATE TABLE links (
id INTEGER PRIMARY KEY,
source_file_id INTEGER NOT NULL REFERENCES files(id),
target_path TEXT NOT NULL,
target_file_id INTEGER,
display_text TEXT,
line_number INTEGER,
UNIQUE(source_file_id, target_path, line_number)
);- Rebuild is a single transaction: delete everything, re-insert. For ~530 files this is sub-second.
- Query methods:
getBacklinks(),getForwardLinks(),getVaultStats(),searchFiles()— all just SQL queries against the indexed data. - Uses WAL mode for safe concurrent reads.
Exposes 5 tools Claude can call directly from any conversation:
| Tool | What it does |
|---|---|
vault_backlinks |
"What files link TO this file?" |
vault_forward_links |
"What does this file link TO?" |
vault_stats |
Graph overview: most-connected nodes, orphans, broken links |
vault_search_links |
Find files by name/path fragment with link counts |
vault_rebuild |
Force a full reindex |
Freshness check: Before every tool call, it walks the vault checking mtime against the last rebuild timestamp. If any .md file changed, it rebuilds automatically. If nothing changed, the response is instant from SQLite.
- Claude Code session starts → reads
.mcp.json→ spawnsnode dist/index.js - On startup: full vault parse + SQLite rebuild (~530 files, ~2,500 links, <1 second)
- During session: lazy freshness checks, instant queries
- Session ends → process dies. SQLite file persists but is treated as a disposable cache.
The index is derived, not authoritative. The markdown files remain the source of truth — the SQLite database is just a navigational cache. This is why a full DELETE + re-INSERT rebuild strategy works: you never lose data because the vault always has the canonical copy.
cd tools/entity-index
npm install
npm run buildAdd to .mcp.json:
{
"entity_index": {
"command": "node",
"args": ["tools/entity-index/dist/index.js"],
"env": {
"VAULT_PATH": "/path/to/your/obsidian/vault"
}
}
}@modelcontextprotocol/sdk— MCP server framework (stdio transport)better-sqlite3— SQLite bindings for Node.js- TypeScript + Node.js
A vault with 530 files and 2,500 links becomes instantly queryable. Example outputs:
Backlinks query — "What links to Matt.md?":
{
"file": "06-people/Matt.md",
"backlinks": [
{"from": "00-home/_current.md", "line": 12, "display": "Matt"},
{"from": "03-living-docs/Management-Philosophy.md", "line": 45, "display": "Matt"},
{"from": "04-meetings/1-on-1s/Matt-1on1.md", "line": 3, "display": "Matt"}
],
"count": 101
}Vault stats — graph health at a glance:
{
"totalFiles": 530,
"totalLinks": 2495,
"mostLinkedTo": [
{"path": "06-people/Matt.md", "incoming": 101},
{"path": "05-projects/show-notes/_index.md", "incoming": 64},
{"path": "03-living-docs/Management-Philosophy.md", "incoming": 54}
],
"orphans": ["...107 files with no incoming links..."],
"brokenLinks": ["...477 links to non-existent files..."]
}