Skip to main content
Glama
Furkiozknn

local-notes-search-mcp

by Furkiozknn
README.md
<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/>

[![CI](https://github.com/Furkiozknn/local-notes-search-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Furkiozknn/local-notes-search-mcp/actions/workflows/ci.yml)
[![Tests](https://img.shields.io/badge/tests-50-3fb950?logo=pytest&logoColor=white)](tests/)
[![License: MIT](https://img.shields.io/badge/license-MIT-8957e5)](LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-3776ab?logo=python&logoColor=white)](.python-version)
[![MCP](https://img.shields.io/badge/MCP-server-000000?logo=anthropic&logoColor=white)](https://modelcontextprotocol.io)

[![No API key](https://img.shields.io/badge/indexing%20%2B%20search-no%20API%20key-3fb950)](#-why-this-architecture)
[![Offline](https://img.shields.io/badge/retrieval-100%25%20offline-3fb950)](#-why-this-architecture)
[![No server](https://img.shields.io/badge/infra-zero%20daemons-3fb950)](#-why-this-architecture)
[![Optional LLM](https://img.shields.io/badge/optional-LLM%20Q%26A-4c8dff)](#-mcp-tools)
[![Storage](https://img.shields.io/badge/storage-sqlite--vec-003b57?logo=sqlite&logoColor=white)](https://github.com/asg017/sqlite-vec)
[![Embeddings](https://img.shields.io/badge/embeddings-fastembed%20ONNX-ff6b35)](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 &gt; 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

A4.3/5.0

Scored across 5 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness5/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues