local-rag
by wesglockzin
README.md
# local-rag-mcp
[](https://github.com/wesglockzin/local-rag-mcp/actions/workflows/ci.yml)
> **A read-only MCP server for semantic search over a local document corpus —
> on-device embeddings (Ollama), a local Chroma store, nothing leaves the
> host.** Built for environments where the corpus content cannot go to a
> cloud API, and served identically to every MCP client (Claude Code, Codex,
> anything speaking the protocol).
This is the MCP-served sibling of
[claude-code-session-memory](https://github.com/wesglockzin/claude-code-session-memory):
same embedding model, same instruction-prefix regime, same measurement
methodology — one retrieval substrate, two consumers. The session-memory
README carries the full eval story (pre-committed bars, adversarial query
sets, regression attribution); this repo applies the same discipline to a
server instead of a hook.
## Tools
| Tool | What it does |
|---|---|
| `search_corpus(query, k=4)` | Semantic search: up to *k* chunks with source path, heading path, cosine score, text |
| `get_file(path)` | Text of an **indexed** document (capped at 50 k chars) — deliberately not a general filesystem reader |
Both are annotated read-only. Failures return structured `{"error": ...}`
payloads — a down dependency degrades the tool, never the session.
## Quickstart
```bash
git clone https://github.com/wesglockzin/local-rag-mcp
cd local-rag-mcp
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
ollama pull embeddinggemma
# Index the included sample corpus (or point RAG_CORPUS_DIR at your own)
./.venv/bin/python ingest.py
# Register with Claude Code — ABSOLUTE paths on both sides: the MCP client
# launches the server from its own working directory, so relative paths are
# the #1 install failure.
claude mcp add local-rag -- "$PWD/.venv/bin/python" "$PWD/server.py"
```
Then ask Claude Code something the corpus knows — "who gets paged for a
sev-1?" — and watch it call `search_corpus`.
Configuration is three environment variables: `RAG_CORPUS_DIR` (default:
`./sample-corpus`), `RAG_STORE_DIR` (default: `~/.local-rag-mcp/store`),
`OLLAMA_HOST`.
## Design decisions that earn their keep
- **The server is read-only and never creates stores.** Ingestion owns
creation. A read-only server that quietly initializes an empty store turns
"you forgot to ingest" into "search returns nothing" — the worse failure,
because it looks like an answer.
- **Embed-then-swap ingest.** A file's old chunks are deleted only after
every new chunk embedded successfully; an Ollama failure mid-file never
leaves that file missing from the index.
- **Retired documents are pre-filtered, not post-filtered.** A document with
`lifecycle: superseded` in its frontmatter is excluded by a `where` clause
*before* the vector search, so it never occupies a result slot. Ingest
writes the lifecycle key explicitly on every chunk — on some versions of
the store a missing key slips through `$ne`, so absence is not a safe
default. (The original of this rule exists because a re-ingest once
silently erased the marker and a retired document resurfaced in results;
a regression test now pins it.)
- **`get_file` is symlink-hardened.** Only indexed paths are readable, and a
path that resolves somewhere different than it did at ingest time is
refused — otherwise anyone who can swap a corpus file for a symlink reads
outside the corpus through the server. If the file is absent on disk
(moved corpus, different machine), the indexed chunk text is served
instead, in chunk order.
- **The store is machine-local, always.** It's a live SQLite-backed
database; cloud sync does whole-file replacement with no transactional
awareness, and the failure mode is a silently corrupted index on the
machine that didn't write it. Sync the corpus and this recipe; every
machine builds its own store.
- **Every ingest stamps the corpus git commit** into its output, so an index
build can be pinned to exactly the corpus state that produced it
("uncommitted changes present" is itself a warning label).
- **Asymmetric embedding prefixes** (EmbeddingGemma's documented query/doc
instruction prefixes) on both sides of the search, matching the companion
project's measured regime — prefixed beat raw retrieval by double digits
there, and mixed prefixed/raw vectors score in an uncalibrated band.
## Corpus conventions
Any directory of `*.md` files works. Three optional frontmatter keys:
```yaml
rag: false # exclude this file from the index entirely
rag_chunk: headings # heading-split a long document (default: whole-file)
lifecycle: superseded # keep the file, hide it from search
```
The committed `sample-corpus/` exercises all three plus a plain file — six
fictional platform-team documents, generated by `tools/gen_sample_corpus.py`
(CI verifies the committed corpus matches the generator).
## Tests
```bash
pip install pytest && python -m pytest -q
```
No Ollama, no store: the embedder is stubbed and the collection is a fake
that records calls. Under test are the contracts — argument validation, the
lifecycle pre-filter reaching the store as a `where` clause, the read-only
no-create guarantee, symlink refusal, embed-then-swap ordering (including
the embedder-down path), the mtime tolerance skip, and the chunker's merge
and oversize-split behavior.
## Known limitations
- **Trust model:** the server reads whatever corpus you point it at, and
clients inject retrieved text into model context. Index only content you
trust — a hostile document is a prompt-injection vector; the server
retrieves, it doesn't sanitize. Stdio MCP has no authentication layer;
it inherits the trust of the process that launched it.
- Scores are comparable only within one embedding regime; a calibrated
"weak match" floor is corpus-specific (the companion repo documents the
calibration method).
- One store, one collection — multi-corpus routing is out of scope here.
- No hybrid keyword+vector stage; paraphrase headroom is measured and
documented in the companion repo.
## License
MIT — see [LICENSE](LICENSE).
## Author
Wes Glockzin
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues