local-notes-search-mcp
<div align="center">
<img src="assets/banner.svg" alt="local-notes-search-mcp - semantic search over your own files, as an MCP server" width="100%">
# π local-notes-search-mcp
### **Semantic search over your own files β as an MCP server.**
*Ask questions in plain language instead of guessing the exact keyword you typed six months ago.*
<br/>
[](https://github.com/Furkiozknn/local-notes-search-mcp/actions/workflows/ci.yml)
[](tests/)
[](LICENSE)
[](.python-version)
[](https://modelcontextprotocol.io)
[](#-why-this-architecture)
[](#-why-this-architecture)
[](#-why-this-architecture)
[](#-mcp-tools)
[](https://github.com/asg017/sqlite-vec)
[](https://github.com/qdrant/fastembed)
<br/>
**πΉπ· [TΓΌrkΓ§e README β](README.tr.md)**
</div>
---
## β¨ What it does
Point it at a folder β project notes, a scattered `Claude projeler/` tree, a docs
directory β and search it by **meaning**, not by exact string match.
```text
βΈ index_directory("C:/Users/you/Desktop/notes")
β
128 files indexed Β· 941 chunks Β· 12 unchanged (skipped)
βΈ search_notes("what did I decide about the auth redesign?")
π― 3 results:
ββ notes/2026-08-decisions.md:12-24 Β· distance 0.31 ββββββββββββββ
## Auth redesign
Decided: session-based instead of JWT, because the refresh-token
rotation story was getting worse than the problem it solved...
ββ notes/meeting-2026-07-30.md:88-101 Β· distance 0.44 ββββββββββββ
...agreed to revisit auth after the billing migration ships.
```
Nothing in that flow touched the network. No OpenAI key, no Pinecone account,
no Docker container, no `docker compose up` before you can search your own
notes.
Want a synthesized answer instead of a result list? π‘ **`ask_notes`** runs the
exact same retrieval, then has an LLM answer *grounded only in the retrieved
chunks*, with `file:line` sources attached. It's strictly **opt-in** β set
`GROQ_API_KEY` or `MISTRAL_API_KEY` and it synthesizes; set neither and it
quietly returns the raw matches instead of failing.
---
## π§ The 30-second pitch
| | grep / ripgrep | Cloud RAG SaaS | **local-notes-search-mcp** |
|---|:---:|:---:|:---:|
| Finds *"the auth decision"* when you wrote *"session vs JWT"* | β | β
| β
|
| Works with **no API key** | β
| β | β
|
| Your files **never leave the machine** | β
| β | β
|
| **No server / daemon / container** to run | β
| β | β
|
| Answers with exact `file:line` you can jump to | β
| β οΈ | β
|
| Costs money per query | β
free | β | β
free |
| Usable directly by Claude / any MCP client | β | β οΈ | β
|
| Optional grounded LLM answer with sources | β | β
| β
opt-in |
---
## ποΈ How it works
```mermaid
flowchart LR
subgraph INDEX["π₯ Index pipeline β runs when you ask it to"]
direction LR
A["π Local folder"] --> B["πΆ Walk + filter<br/>skip .git, node_modules,<br/>.venv, files > 2MB"]
B --> H{"π Content hash<br/>changed?"}
H -- "no" --> SKIP["βοΈ Skip<br/>zero CPU"]
H -- "yes" --> C["βοΈ Line-based chunker<br/>1500 chars + 200 overlap<br/>never splits a line"]
C --> D["π§ fastembed ONNX<br/>paraphrase-multilingual-MiniLM-L12-v2 Β· 384-d"]
end
D --> DB[("ποΈ sqlite-vec<br/>vec0 virtual table<br/>~/.local-notes-search/index.db")]
subgraph QUERY["π Query path β 100% offline"]
direction LR
Q["π¬ Natural-language<br/>question"] --> QE["π§ Embed query<br/>same model"]
end
QE --> DB
DB --> R["π― Top-k chunks<br/>file:line + snippet<br/>+ distance score"]
R -. "opt-in: ask_notes<br/>needs an API key" .-> LLM["π€ LLM synthesis<br/>Groq β Mistral fallback<br/>grounded in retrieved chunks only"]
LLM --> ANS["π‘ Answer + file:line sources"]
style LLM fill:#1c1730,stroke:#a371f7,color:#ffffff
style ANS fill:#1c1730,stroke:#a371f7,color:#ffffff
style DB fill:#003b57,stroke:#00b4d8,color:#ffffff
style R fill:#1a7f37,stroke:#3fb950,color:#ffffff
style SKIP fill:#4d3800,stroke:#d4a72c,color:#ffffff
```
---
## π§° MCP tools
| π οΈ Tool | What it does |
|---|---|
| ποΈ **`index_directory(path, extensions=None)`** | Recursively indexes a directory. Skips `.git` / `node_modules` / `.venv` / `__pycache__` / `dist` / `build` and anything over 2 MB. Unchanged files are skipped via a cheap hash check; deleted files are purged from the index. |
| π **`search_notes(query, top_k=5, path_prefix=None)`** | Natural-language semantic search. Returns `file:line-range` + snippet + distance score β not just a bag of filenames. `path_prefix` scopes the search to one subtree. |
| π‘ **`ask_notes(question, top_k=5, path_prefix=None)`** | *Optional.* Same retrieval as `search_notes`, then an LLM (Groq β Mistral fallback) answers using **only** those chunks, followed by a `file:line` source list. Needs `GROQ_API_KEY` or `MISTRAL_API_KEY`. With neither key set β or if the provider chain fails β it degrades to returning the raw matches with a note. It never hard-fails just because synthesis wasn't possible. |
| π **`list_indexed_files(path_prefix=None)`** | What's in the index right now: path, chunk count, last-indexed timestamp. Useful before searching, or to debug a stale result. |
| π§Ή **`remove_directory(path)`** | Drops everything under `path` from the index. **Does not delete your files** β it only cleans the index. |
> π **`index_directory`, `search_notes`, `list_indexed_files` and `remove_directory`
> require no API key and make no network calls at all.** `ask_notes` is the one
> tool that can talk to a remote provider, and only when you explicitly give it
> a key.
---
## π Quickstart
```bash
git clone https://github.com/Furkiozknn/local-notes-search-mcp.git
cd local-notes-search-mcp
uv sync
```
<details>
<summary><b>π Wire it into an MCP client (Claude Code, Claude Desktop, β¦)</b></summary>
<br/>
Register `local_notes_search.py` as a **stdio** MCP server:
```json
{
"mcpServers": {
"local-notes-search": {
"command": "uv",
"args": [
"--directory", "/absolute/path/to/local-notes-search-mcp",
"run", "local_notes_search.py"
]
}
}
}
```
On the **first** `index_directory` / `search_notes` call, the fastembed model
(~130 MB) is downloaded once and cached locally. Every call after that is fully
offline.
</details>
<details>
<summary><b>βοΈ Configuration</b></summary>
<br/>
| Env var | Default | What it does |
|---|---|---|
| `LOCAL_NOTES_SEARCH_DB` | `~/.local-notes-search/index.db` | Where the index lives. **One single file for every indexed directory** β so a single `search_notes` call can span all your project folders at once. |
| `LOCAL_NOTES_SEARCH_ALLOWED_ROOTS` | *unset* | Optional allowlist. When set, `index_directory` refuses any path that does not resolve inside one of these directories. `os.pathsep`-separated (`:` on Linux/macOS, `;` on Windows). |
| `GROQ_API_KEY` | *unset* | Optional. Enables `ask_notes` synthesis via Groq (first in the provider chain). |
| `MISTRAL_API_KEY` | *unset* | Optional. Fallback provider for `ask_notes` when Groq is unset or fails. |
Keys are read from the environment only β **never commit them, and never put
them in the MCP client config file you check into git.**
Default indexed extensions: `.md` `.txt` `.py` `.js` `.ts` `.tsx` `.jsx` `.json`
`.yaml` `.yml` `.rst` `.toml` β override per call with `extensions=[...]`.
### π What can be indexed
`index_directory` reads whatever it is pointed at, and `ask_notes` sends the
chunks it retrieves to a **third-party LLM** (Groq or Mistral) when a key is
configured. So an indexed path is a path whose contents can leave the machine.
Two guards exist:
**1. `LOCAL_NOTES_SEARCH_ALLOWED_ROOTS` (opt-in).** Unset by default β that is
the historical behaviour, any directory the running user can read is
indexable, and this project does not pretend an empty default is a sandbox.
Set it and `index_directory` refuses anything outside:
```bash
export LOCAL_NOTES_SEARCH_ALLOWED_ROOTS="$HOME/notes:$HOME/projects"
```
Paths are resolved (`..` collapsed, symlinks followed) before the check, and a
subdirectory of an allowed root is allowed. A configured entry that is not a
directory is an error rather than being silently dropped β a typo must not
quietly switch the allowlist off.
**2. A credential-filename denylist (always on).** These are never indexed,
whatever the allowlist or the `extensions=[...]` argument says:
`.env` Β· `.env.*` Β· `.netrc` Β· `_netrc` Β· `id_rsa` Β· `id_dsa` Β· `id_ecdsa` Β·
`id_ed25519` Β· `credentials.json` Β· `*.pem`
Matching is case-insensitive. It is a **name** denylist, not a secret scanner:
it stops the obvious cases (`credentials.json` would otherwise sail through the
default `.json` extension filter), not a key pasted into a `.md` file.
</details>
---
## π§ Why this architecture
| Decision | Why |
|---|---|
| ποΈ **`sqlite-vec` (Apache-2.0)** for vector storage | A `vec0` virtual table inside one ordinary `.sqlite` file β **no daemon, no Docker, no hosted service**. Qdrant and pgvector were evaluated and rejected *specifically* because both need a running server process. A personal notes index should not require ops. |
| β‘ **`fastembed` (Apache-2.0), not `sentence-transformers`** | Local embedding here is the **only** path β it runs on every index and every search. `sentence-transformers` drags in torch (~1 GB); that's an acceptable price for a rarely-hit fallback, but not for the hot path. fastembed's quantized ONNX models land around **100β150 MB with no torch at all**. A deliberate divergence, documented in the module docstring. |
| 𧬠**`paraphrase-multilingual-MiniLM-L12-v2`, 384 dims** | Small (0.22 GB), Apache-2.0, and β decisive for this tool β **actually multilingual**: the previous `bge-small-en-v1.5` was an English-only model quietly embedding Turkish notes. Symmetric, so queries and passages embed identically. Override with `LOCAL_NOTES_SEARCH_MODEL`; a mismatched existing index is refused, never silently compared. **No GPU required.** |
| βοΈ **Line-based chunking, no NLP/AST dependency** | Chunks accumulate whole lines until a character budget is hit β **a line is never split in half**, so every `file:line` reference the tool returns is exact. An overlap window keeps context alive across boundaries. Deterministic, and fully unit-testable without loading the embedding model. |
| π **Whole-file content-hash skip on re-index** | `index_directory` is designed to be re-run constantly. Re-embedding unchanged files would burn CPU on every single call for zero benefit β one cheap hash comparison avoids it. |
---
## π§ͺ Tests
```bash
uv run pytest -v
```
**50 tests, on a deliberate two-tier strategy.** Pure-logic tests (chunking,
hashing, file walking, `ask_notes`' provider-chain and degradation paths)
always run β no model, no network, no API key. Tests that need the real
fastembed model or the sqlite-vec extension **skip honestly** when those can't
be loaded β an offline runner, a blocked model download β rather than faking a
green result.
What that means in practice, reported exactly as measured:
| Environment | Result |
|---|---|
| β
Development environment (fastembed model downloadable) | **64 tests**, including the real end-to-end flow β the fastembed model really loaded, the sqlite-vec extension really ran, and a *"how do I bake a cake"* query really retrieved the relevant file while excluding the irrelevant one. |
| β οΈ A sandbox with the model download blocked | **50 passed, 14 skipped** β measured 15 September 2026. Every model-free test green, and the model-backed ones skipped with an explicit reason instead of a false pass. |
The second row is the honest cost of the first: this suite tells you when it
*couldn't* verify something.
---
## β οΈ Known limitations
<img src="assets/limits.svg" alt="What never leaves the machine - walking, hashing, chunking, embedding and the whole vector search path, with unchanged files skipped by content hash and results carrying file, line and distance - against what is opt-in or honestly unfinished: ask_notes needs an API key and is grounded only in retrieved chunks, an overridden asymmetric model gets no query prefix, CI re-downloads the model each run, and SQLite is a single writer." width="100%">
Written down on purpose, because a README that claims no weaknesses is a README
you shouldn't trust.
- **No query-instruction prefix is needed anymore.** The previous
English-only `bge-small-en-v1.5` recommended embedding queries with an
instruction prefix, which this tool skipped as a v1 simplification. The
current default, `paraphrase-multilingual-MiniLM-L12-v2`, is a symmetric
model: queries and passages are *meant* to embed identically, so the
simplification is now simply the correct usage. If you override
`LOCAL_NOTES_SEARCH_MODEL` with an asymmetric model (BGE/E5 family),
know that its prefix convention is still not applied.
- **CI re-downloads the fastembed model on every run** (no `actions/cache`
configured). Acceptable for a small project; easy to speed up later. Low
priority, and honestly labelled as not done.
- **Single-writer SQLite.** Concurrent `index_directory` / `search_notes` calls
from *separate processes* can collide on writes. The tool is designed around
a single MCP client session.
---
## π License
[MIT](LICENSE) β and every runtime dependency was license-checked:
`sqlite-vec` (Apache-2.0), `fastembed` (Apache-2.0), `mcp` (MIT),
`litellm` (MIT). No non-commercial or field-restricted weights anywhere in
the stack.
<div align="center">
<br/>
**Built as part of an ecosystem of small, focused, self-hostable AI tools.**
</div>
TDQS
Scored across 5 tools
Each tool has a clearly distinct role: index_directory ingests data, search_notes and ask_notes are differentiated as raw retrieval vs. LLM-synthesized answers, list_indexed_files provides observability, and remove_directory cleans up. Even the two retrieval-based tools are easy to tell apart because their outputs and purposes are explicit.
All tool names follow a consistent snake_case verb_noun pattern: index_directory, search_notes, list_indexed_files, ask_notes, remove_directory. There are no mixed conventions or vague generic verbs.
Five tools is a well-scoped surface for a local notes search server: ingest, search, ask, list, and remove each earn their place. The count is neither bloated nor too thin.
The index lifecycle is fully covered: create/update via index_directory, read via search_notes and ask_notes, inspection via list_indexed_files, and deletion via remove_directory. Re-indexing handles changed and removed files, so there are no obvious dead ends or missing operations.