Skip to main content
Glama
cusster

smart-connections-mcp

by cusster

smart-connections-mcp

Read-only MCP server giving Claude Code semantic search over an Obsidian vault, using the embeddings the Smart Connections 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). 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.

Related MCP server: obsidian-emergent-mcp

Install

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:

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:

"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.

{
  "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.

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:

{
  "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:

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:

"embeddings": { "TaylorAI/bge-micro-v2": { "vec": [ ... ] } }

They now live in a packed binary, with the .ajson holding only a pointer:

"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_searchsearch_notes, find_relatedrelated_notes, get_context_blockssearch_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.jsonlookup_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:

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

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:

SMART_VAULT_PATH=... node src/verify.js "cache serving stale data after a config change"

Tests

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables Claude to perform semantic search across your Obsidian vault using Smart Connections vector database. Provides meaning-based search, related note discovery, and context retrieval for RAG queries instead of basic keyword matching.
    10
    -
  • A
    license
    Not graded
    quality
    D
    maintenance
    Provides Claude Code with deep access to an Obsidian vault through 28 tools for structural analysis, semantic retrieval, and git-backed timeseries tracking. It transforms your vault into a live knowledge base that Claude can search, navigate, and reason about using its knowledge graph.
    2
    MIT
  • F
    license
    A
    quality
    C
    maintenance
    Enables Claude Code to interact with Obsidian vaults by providing tools for multi-term searching, reading notes, and exploring tag-based relationships. It allows users to query, analyze, and manage their personal knowledge base directly through natural language.
    9
    -