smart-connections-mcp
by cusster
README.md
# smart-connections-mcp
Read-only MCP server giving Claude Code semantic search over an Obsidian vault,
using the embeddings the [Smart Connections](https://smartconnections.app) plugin
already builds.
It does **not** talk to Obsidian. The plugin owns indexing; this reads its output
files directly — which is why it works across the WSL↔Windows boundary with no
networking, and why it is read-only by construction.
```
you ── Claude Code ── MCP stdio ── this server ──┬── .smart-env/*.ajson (pointers)
├── .smart-env/**/mf_* (float32 vectors)
└── your *.md files (body text, on demand)
Obsidian + Smart Connections ── writes ────┘
```
---
## Requirements
- **Node ≥ 20** (tested on 22.2.0).
- **Obsidian with the Smart Connections plugin v4** (smart-env 3.x), which must
have indexed the vault at least once. Check that `.smart-env/smart_sources/`
contains `smart_sources.ajson` and at least one `mf_*` file.
- **Block embeddings enabled** — the default, and worth confirming, because block
vectors are where most of the vault's text actually lives (see
[Coverage](#notes-are-only-embedded-to-1894-characters--blocks-carry-the-rest)).
In `.smart-env/smart_env.json`: `smart_blocks.embed_blocks: true`.
- ~70MB of disk for the embedding model, downloaded on first use.
No API key, no network at query time: embedding runs locally.
## Install
```bash
git clone https://github.com/cusster/smart-connections-mcp.git ~/workspace/smart-connections-mcp
cd ~/workspace/smart-connections-mcp
npm install
claude mcp add smart-connections --scope user \
-e SMART_VAULT_PATH="/mnt/c/Users/you/Documents/YourVault" \
-- node ~/workspace/smart-connections-mcp/src/index.js
```
`--scope user` makes it available in every project. Drop it to register for the
current project only.
**Then restart Claude Code.** MCP servers are initialised at start-up, so a newly
added server is not available in the session that added it. This is the single
most common reason it appears to do nothing.
Confirm it came up:
```bash
claude mcp list # smart-connections: ... - ✔ Connected
```
Then, in Claude Code, call `index_status` first — it reports what was actually
loaded, and a healthy vault shows a non-zero count for **both** notes and blocks.
### Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `SMART_VAULT_PATH` | *(required)* | Absolute path to the vault root — the folder containing `.obsidian/`, not `.smart-env/` |
| `SMART_MODEL_CACHE` | `~/.cache/smart-connections-mcp` | Where the ONNX weights live |
| `SMART_EMBED_MODEL` | `TaylorAI/bge-micro-v2` | Only change this if the plugin is configured for a different model — it must match, or every result is confident nonsense |
| `SMART_VERIFY_SAMPLE` | `12` | Sample size per collection for `npm run verify` |
WSL note: use the `/mnt/c/...` form, not `C:\...`.
## Tools
### `search_notes`
Semantic search by meaning. Ranks note-level and block-level vectors together and
returns one row per note, tagged with which one matched.
| Parameter | Type | Default | Notes |
|---|---|---|---|
| `query` | string | *(required)* | Natural language, max 8192 chars. Phrasing close to how the note puts it works best |
| `limit` | number | `10` | Clamped to 1–100 |
| `min_score` | number | `0.5` | Cosine similarity, clamped to −1–1 |
| `scope` | `auto` \| `notes` \| `blocks` | `auto` | `notes` covers only each note's first 1894 chars; `blocks` pinpoints passages |
| `folder` | string \| string[] | *(all)* | Restrict to one or more folders, e.g. `"ProjectA"` or `["ProjectA/Planning", "ProjectB"]` |
| `include_text` | boolean | `true` | For a block hit the excerpt is the matched section, not the top of the note |
**Scoping a multi-project vault.** If one project holds most of the notes it
dominates every generic query, and the smaller projects become effectively
unreachable. `folder` filters the candidate pool *before* ranking, so `limit`
means "the best N inside this folder" rather than "whatever survives of the best
N overall" — a post-filter would routinely return nothing for a small project.
Matching is on whole path segments and case-insensitive, so `"projecta"` works
and `"Project"` does not silently match `ProjectA` and `ProjectB`. Any depth is
accepted, so this works whether a vault puts projects at the top level or nests
everything under one root. `index_status` advertises folders two levels deep with
their note and block counts, which is how a caller discovers what it may scope
to — a single-level listing would make `Notes/Work/ProjectA`-shaped vaults
undiscoverable, since every note would sit under one entry.
Scoping removes competition; it does not create relevance. Narrowing to a project
that never discussed the topic returns its least-irrelevant notes at low scores —
read the scores, not just the ordering.
**A scoped response says what the filter hid.** Otherwise a narrow search is
indistinguishable from a vault with nothing on the subject, which is the same
class of silent-wrong-answer this server works to avoid everywhere else:
```json
"folder": ["ProjectB"],
"scoped_out": {
"notes_hidden_by_folder": 233,
"best_hidden": { "path": "Notes/2026/incident-review.md", "score": 0.6546 },
"better_match_outside_folder": true,
"hint": "A better match exists outside this folder. Re-run without `folder` if the question is not project-specific."
}
```
`better_match_outside_folder` is true only when the filter removed something that
scored higher than anything it returned — a warning on every scoped search would
just train the caller to ignore it. `scoped_out` is absent entirely when no
folder was passed.
Example response — this shows the *shape*. Scores and paths depend entirely on
your vault.
```json
{
"query": "why did we split the cache into two instances",
"mode": "semantic",
"model": "TaylorAI/bge-micro-v2",
"scope": "auto",
"results": [
{
"path": "Notes/2026/infrastructure-journal.md",
"score": 0.661,
"matched": "note",
"excerpt": "# Infrastructure journal\n\n## Cache topology …"
},
{
"path": "Notes/2026/incident-review.md",
"score": 0.6594,
"matched": "block",
"section": "#Incident review#Root cause#{3}",
"lines": [23, 23],
"excerpt": "- **Resolution:** separate instances, because one eviction policy cannot serve both …"
}
]
}
```
Note `matched`: the first hit came from a whole-note vector, the second from a
single section, with the line range it occupies. A block hit's excerpt is that
section rather than the head of the note.
**Score calibration** (measured on this model): `<0.60` usually noise, `0.65+` a
real match, `0.80+` strong. One caveat — the plugin prefixes every embedding with
the note's path as breadcrumbs, so a query that is really just a path fragment
(a folder name plus a year, say) scores 0.72–0.75 on folder structure alone without matching any
content. Judge those by the excerpt, not the score.
### `related_notes`
Notes related to an existing note — the Smart Connections sidebar, as a tool.
| Parameter | Type | Default |
|---|---|---|
| `path` | string | *(required)* vault-relative, e.g. `Projects/Deucalion.md` |
| `limit` | number | `10` (1–100) |
| `folder` | string \| string[] | *(all)* — same matching as `search_notes` |
### `get_note`
Read a note by vault-relative path.
| Parameter | Type | Default | Notes |
|---|---|---|---|
| `path` | string | *(required)* | |
| `max_chars` | number | `100000` | Ceiling 400,000; longer notes are truncated with a marker |
Paths are validated: traversal, absolute paths, symlinks pointing out of the
vault, and **dot-folders** are all refused. That last one matters —
`.obsidian/plugins/*/data.json` is where Obsidian plugins keep API keys, and it
is not note content.
### `index_status`
Index health. Call this first when results look wrong. Field meanings:
`stale_*` counts vectors whose source file has changed since they were written;
`unresolvable_pointers` counts index entries whose vector could not be read, each
of which is a note that silently will not be findable; `embedding_files_in_use`
shows which multifiles the loaded vectors actually came from — more than one per
collection means the index carries refs from more than one embedding run;
`model_matches_query_embedder` is the one to check first when results look wrong.
Any problem it detects is also spelled out in a `warnings` array.
Example response — shape, not a benchmark:
```json
{
"vault": "/mnt/c/Users/you/Documents/YourVault",
"embedded_notes": 586,
"embedded_blocks": 4005,
"stale_notes": 0,
"stale_blocks": 0,
"deletion_tombstones_applied": 1280,
"unresolvable_pointers": 0,
"load_retries_from_concurrent_reindex": 0,
"embedding_files_in_use": { "smart_sources": ["mf_ccarbz"], "smart_blocks": ["mf_ccarbz"] },
"model": "TaylorAI/bge-micro-v2",
"dims": 384,
"multifiles": { "smart_sources": ["mf_ccarbz"], "smart_blocks": ["mf_ccarbz"] },
"index_loaded_at": "2026-09-11T07:30:08.014Z",
"notes_missing_on_disk": 0,
"note": "...",
"coverage_note": "..."
}
```
## Performance
Timings depend heavily on where the vault lives. On a Windows-hosted vault
reached through `/mnt/c`, cost is dominated by per-file syscalls —
`realpathSync` alone runs to double-digit milliseconds per call — not by vector
maths. A native-filesystem vault is far quicker.
Rather than quote figures that will not match your setup:
```bash
SMART_VAULT_PATH=/path/to/vault npm run measure
```
Section 4 of its output times a cold load, a warm load, a search with and
without excerpts, and `index_status`.
What the code does to keep that bearable: the parsed index is reused until the
underlying files' mtime/size change; concurrent tool calls share one parse rather
than each re-reading the index; resolved paths and directory listings are
memoised for the life of an index revision; the missing-file count is computed
once per revision; excerpts read only as far into a note as the quoted lines
require. A vault that Obsidian is actively re-indexing invalidates the signature
frequently, so expect more full reloads while it works.
## Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Tool not available at all | Claude Code wasn't restarted after `claude mcp add`. Servers load at start-up |
| `SMART_VAULT_PATH is required` | Env var missing from the MCP registration; check `claude mcp get smart-connections` |
| `no Smart Connections index at …/smart_sources.ajson` | Wrong vault root (point at the folder containing `.obsidian/`), or the plugin has never indexed. Open the vault in Obsidian and wait |
| `embedded_blocks: 0` | `smart_blocks.embed_blocks` is off, or the plugin hasn't embedded blocks yet. Expect ~73% worse coverage on long notes until it has |
| `embedding model mismatch … refused` | The vault was indexed with one model and this server embeds queries with another. Searching across two vector spaces returns confident nonsense, so it is refused. Set `SMART_EMBED_MODEL` to the vault's model, or re-index the vault |
| Results are confidently irrelevant | Check `index_status.model_matches_query_embedder`, then run `npm run verify`, which measures the vector-space agreement directly |
| Recently edited notes rank on their old content | Vectors are written only while Obsidian runs. `stale_notes` counts them |
| `index changed under every one of 4 read attempts` | Obsidian is re-indexing right now. Deliberate refusal — vectors read across a compaction belong to the wrong notes. Retry in a few seconds |
| First call is slow, then fine | One-off model download/load. Cached in `SMART_MODEL_CACHE` |
---
# How it works
## Why this exists rather than an off-the-shelf server
Smart Connections v4 (smart-env 3.x) changed its storage format. Vectors used to
live inline in `.ajson`:
```json
"embeddings": { "TaylorAI/bge-micro-v2": { "vec": [ ... ] } }
```
They now live in a packed binary, with the `.ajson` holding only a pointer:
```json
"embedding": { "default": { "mf_ccarbz": { "file": "mf_ccarbz", "file_i": 18, "at": 1789035930404 } } }
```
Community servers written for the old shape don't degrade — they return nothing,
or fall back to keyword matching, which looks like working RAG and is not.
Checked against one such server (`dan6684/smart-connections-mcp`) on a real v4
vault, three independent blockers:
- it reads `.smart-env/multi/`, which v4 does not create — the loader returns
early with **zero vectors** and searches then come back empty, with no error;
- its vector lookup wants `item['embeddings']['TaylorAI/bge-micro-v2']['vec']`,
and the string `"embeddings"` appears **0 times** in a v4 index;
- its `get_context_blocks` returns `item['text']`, but v4 stores no text at all
— every `"text"` field in the index is `null`. Body text has to be read from
the markdown files.
Its three tools map onto this server as `semantic_search` → `search_notes`,
`find_related` → `related_notes`, `get_context_blocks` → `search_notes` with
`scope: "blocks"`.
## Format notes (measured against a live vault, not documented)
- `.smart-env/smart_sources/smart_sources.ajson` is an **append-only log**. A key
may recur; the **last** occurrence wins. Treating it as a plain map serves stale
vectors.
- A line whose value is `null` is a **deletion tombstone**. Skipping those leaves
the previous record standing as "last-wins", so deleted and renamed notes keep
ranking forever with an empty excerpt.
- `embedding.default` is a **map keyed by model fingerprint** (which defaults to
the multifile name), so a note can carry several refs — different models, or a
pre- and post-compaction file. The right one is the greatest `at` among refs
that have a `file`, and the multifile name comes from `ref.file`, **not** from
the map key. This mirrors the plugin's own `get_embedding_ref`.
- `.smart-env/{smart_sources,smart_blocks}/mf_<id>` is packed `float32[384]`,
**1536 bytes per record, no header**. `file_i` is the slot index.
- The two directories hold **different files that share a name**:
`smart_sources/mf_ccarbz` and `smart_blocks/mf_ccarbz` are unrelated. A buffer
cache keyed on the bare filename resolves block pointers against the source
file and returns unit-norm vectors for the wrong content, with no error.
- Vectors are **L2-normalised** (measured norm 1.0), so cosine similarity is a
plain dot product.
- The index stores **no note text** (`"text": null` throughout) — only locations.
- Block line ranges (`blocks_data[...].lines`) are **1-based and inclusive**,
matching the plugin's `lines.slice(line_start - 1, line_end)`.
- The multifile **name is not stable** and the index **grows and is rewritten
while Obsidian runs**.
## Notes are only embedded to 1894 characters — blocks carry the rest
This is the most important thing about retrieval quality here, and it is not a
defect in the plugin.
The plugin embeds a note as `${breadcrumbs}:\n${content}` truncated to
`floor(max_tokens * 3.7)` = **1894 characters** (`max_tokens: 512` for
bge-micro-v2 — both constants come from the plugin bundle, so they do not drift).
Most notes of any length exceed that, which leaves the bulk of a vault's text
outside any *note-level* vector.
The plugin compensates deliberately: `select_block_embedding_plan` computes a
*loss* term and descends into child blocks precisely when a parent would
overflow, and the plugin's own semantic lookup searches blocks rather than
sources (`smart_env.json` → `lookup_lists.results_collection_key: "smart_blocks"`).
So reading note vectors alone reproduces neither the coverage nor the ranking of
the plugin itself. That is why `scope: auto` searches both.
**How much this matters on your vault is a question with a real answer — measure
it rather than trust a number from someone else's:**
```bash
SMART_VAULT_PATH=/path/to/vault npm run measure
```
That reports coverage under one stated method, and a recall comparison across a
strided sample of every note long enough to have text past the cap. Two things
worth knowing before you read the output:
- The gain is a **blocks** gain, not an `auto` gain. `scope: "blocks"` alone
performs about as well as `auto`; `auto` mostly adds safety on short notes.
- Adding blocks is **not free** — it regresses a minority of queries, where a
short block outranks the note that actually holds the answer. `measure` prints
that count explicitly rather than hiding it.
The residual is a genuine ceiling, not a bug: blocks larger than the cap with no
sub-headings to descend into get truncated, and blocks under `min_chars` (200 by
default) are never embedded at all.
## Matching the vector space
The load-bearing correctness requirement is that a query is embedded exactly as
the documents were. A mismatch produces no error — just confident nonsense. So
this uses the same library, settings **and truncation algorithm** as the plugin:
| | |
|---|---|
| Library | `@huggingface/transformers` 4.2.0 (same as the plugin) |
| Model | `TaylorAI/bge-micro-v2`, 384 dims |
| Pooling / normalize | `mean` / `true` |
| Query prefix | **none** (BGE retrieval prefixes would shift the query out of the document space) |
| Max tokens | 512 |
| Long-input handling | the plugin's own `prepare_input`, replicated |
Two traps:
⚠️ `transformers.js` truncates against `tokenizer.model_max_length`, which ships
as `1e+30` for this model — so `truncation: true` never fires and any input over
512 tokens **throws** inside onnxruntime. The property is a getter with no
backing field, so it is shadowed via `Object.defineProperty`.
⚠️ The plugin does not rely on the tokenizer for that. It truncates the text
itself first: count tokens, then while over budget, cut to 90% of the
proportional character estimate and back up to the last space. Its stored vector
is therefore built from text that is *shorter* than a 512-token cut and ends on a
word boundary. Replicating that closes a real gap on long blocks — but a modest
one; letting the tokenizer truncate instead still lands close, because both cuts
keep the same leading text. `npm run verify` reports where your vault sits.
(An earlier version of this file claimed the gap was far larger. That figure was
measured while a separate off-by-one bug was shifting every block excerpt by a
line, so it described the wrong cause. Corrected.)
## Verify it actually works
```bash
SMART_VAULT_PATH=/mnt/c/Users/you/Documents/YourVault npm run verify
```
Rebuilds each sampled note's and block's embed input **exactly as the plugin
does**, re-embeds it, and compares to the stored vector. Shared vector space
means ~1.0; drift collapses it. Passes at 0.95, samples randomly each run so a
problem anywhere in the vault can surface, and exits non-zero on failure. It
prints the worst similarity it found — read that, rather than taking a number
from this file.
It cannot detect slot misalignment from a mid-read compaction — those vectors are
all unit-norm and all real, just attached to the wrong notes. That is prevented in
`vault.js` by re-checking the file signature after the read, and covered by
`test/concurrency.test.mjs`.
Pass a query to see live ranking:
```bash
SMART_VAULT_PATH=... node src/verify.js "cache serving stale data after a config change"
```
## Tests
```bash
npm test # fixture-based; no vault or model download needed
npm run test:vault # protocol-level against a real vault (needs SMART_VAULT_PATH)
npm run measure # coverage, recall and timings on YOUR vault
npm run verify # proves the query embedder matches the index
```
The fixture builds a synthetic vault whose slot *i* holds the unit vector *e_i*,
so a resolved vector proves which slot of which file it came from. That is the
only way to catch this format's characteristic failure: the wrong vector, still
unit-norm, with no error. `test/concurrency.test.mjs` races a real second process
against the loader, because `load()` reads synchronously and nothing in-process
can interleave with it.
The suite is checked by mutation: each fix is reverted in a copy of the source
and the suite must go red. That is not ceremony — three tests in an earlier
round passed with their fix removed, including the one guarding the defect this
README calls out most loudly. A green suite that survives its own mutants is the
only kind worth quoting.
## Known limits
- **Coverage is not total.** Oversized indivisible blocks are truncated by the
plugin, and sub-`min_chars` blocks are never embedded. `npm run measure`
reports where your vault lands.
- **Path breadcrumbs inflate some scores.** See the calibration note under
`search_notes`.
- **Small model.** bge-micro-v2 handles close paraphrase well and heavy
abstraction poorly: a query using entirely different vocabulary from the note
can fall into the noise band. Worth separating from the coverage issue above —
misses on text that *is* in the vault were mostly the 1894-char cap, which
block search fixes, not something a bigger model would have solved.
- **Staleness is inherent.** Only Obsidian writes vectors; no MCP server can fix
this. `index_status` reports how many are stale.
- **Concurrent re-indexing can make a load fail**, by design. Retry.
- **Embedding-ref selection differs from the plugin's.** When a note carries refs
from more than one embedding run, this server takes the most recently written
one, while the plugin pins the ref matching its configured model's fingerprint.
On a single-model vault the two always agree. They can diverge mid-migration,
when some notes have been re-embedded and others have not — so rather than
reimplement the plugin's fingerprint hash (a private, undocumented key schema
that would fail silently if it ever changed), the server guards the outcome:
it refuses to search when the vault's model and the query embedder disagree,
and `index_status` warns when vectors resolve from more than one multifile.
## License
MIT — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues