docs-rag-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@docs-rag-mcpsearch the ADRs for why we deprecated the refresh token flow"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
docs-rag-mcp
It isn't a better search engine over your docs. It's a filter on what the agent is allowed to believe: superseded decisions disappear, the threshold says "I don't know" instead of inventing, and you always see why a result got in.
An MCP server that exposes a folder of markdown documents to any MCP client
(Claude Code, Claude Desktop, Codex, Cursor, Zed…) as one search tool,
search_notes. Everything runs on your machine: embeddings go through
Ollama, and nothing leaves your computer at index time
or at query time.
npx -y docs-rag-mcp init # guided questions -> config.json
npx -y docs-rag-mcp index # builds the indexWriting the docs you'll search? How you structure a markdown file decides how well it can be found. See AUTHORING.md — a short guide to writing docs that retrieve well. Five minutes there improves every search.
What actually makes this different
Most of what this tool does, other local RAG servers also do. The part that is genuinely rare is the document lifecycle: the index knows that a document has been superseded, and acts on it.
A doc marked
status: supersededin its frontmatter stops being returned by default. When it does come back — because you asked for history explicitly — it arrives labelled[superseded → reference/auth.md; 2026-03-01], so the successor travels with it.Below
minScore, the tool says "No relevant results (best score 0.41, threshold 0.55)" instead of handing over the closest noise it found. An agent given a bad match treats it as ground truth; a human would have hesitated. The threshold is where that hesitation lives.Every hit shows why it is there:
[semantic 0.712],[both 0.712],[exact match]. Not a black box you have to trust.
Everyone else does file freshness — re-sync, re-index, watch for changes. Nobody does truth freshness. That is the whole point of this tool.
approach | handles document lifecycle? | |
docs-rag-mcp | dense + lexical, SQLite, Ollama | yes — |
| hybrid BM25+dense, AST chunking, Milvus | no |
| semantic+keyword, LanceDB, PDF/DOCX/MD | no |
| markdown, heading chunking, Milvus | no |
| sqlite-vec + Ollama, graph-aware | no |
| pgvector | no |
The rest — local-only, chunking by heading, SQLite storage, incremental indexing — is table stakes in this field, not a differentiator. It's in the feature table below, not in the pitch.
Related MCP server: recall-mcp
When you don't need this
Honest version, because the field moved:
For exact matches on symbols, your agent already has grep, and grep is faster and needs no index. Function names, error codes, config keys: don't build a vector index for that.
Below a few dozen documents, agentic search is enough. The agent reads the file tree, greps, opens what looks relevant. That works.
Anthropic shipped RAG with a vector DB inside Claude Code and then removed it (May 2025), because agentic search outperformed it. Cursor, Windsurf, Cline and others went the same way. Pretending otherwise would be dishonest.
What survives that, and is why this exists: grep finds what you name. When the doc calls it "token refresh window" and you call it "session expiry", grep returns nothing and semantic search returns the doc. And on a long, layered corpus, semantic retrieval costs fewer round-trips and fewer tokens than having the agent walk the tree — not "better results", cheaper results.
So the fit is narrow and specific: a long, layered corpus of decisions, specs and ADRs in markdown, with superseded material in it, where you need the agent not to resurrect a dead decision.
Prerequisites
Node.js ≥ 22.5 — the index uses the built-in
node:sqlitemodule, so there are no native dependencies to build. Depending on your Node version you may see a one-line warning on stderr (ExperimentalWarning: SQLite is an experimental feature); it's harmless.Ollama with an embedding model. The model is required both to build the index and on every search — the query is embedded on the fly, so Ollama must be running whenever the MCP server is in use, not only at index time.
# install Ollama from https://ollama.com, then: ollama pull bge-m3
Install
The published package needs no clone and no build:
npx -y docs-rag-mcp init # guided questions -> writes config.json
npx -y docs-rag-mcp index # builds the indexIf you plan to use the auto re-index hook, install it globally instead:
npm i -g docs-rag-mcpnpx re-resolves the package on every spawn and downloads it on first use.
That is irrelevant for the MCP server, which starts once per session, but the
hook is designed to exit in zero seconds and runs after every file edit — a
global install removes that overhead entirely. docs-rag scaffold detects a
global install and writes the shorter command form on its own.
Prefer editing a config by hand? Copy config.example.json to config.json
and set vaultPath. Everything else has sensible defaults.
git clone https://github.com/andreaselmi/docs-rag-mcp
cd docs-rag-mcp
yarn install
yarn setup # -> config.json
yarn index
yarn build # compiles to dist/The yarn scripts map one-to-one onto the subcommands (setup→init,
serve, index, search, scaffold). When scaffolding a project from a
source checkout, pass --local: it writes node /abs/path/dist/server.js
into the generated files instead of an npx command that would resolve to the
published package rather than your working tree.
The commands
docs-rag init interactive wizard, writes config.json
docs-rag scaffold <dir> give a project its own scoped instance
docs-rag index build or update the index
docs-rag search "question" query the index from the terminal
docs-rag serve run the MCP server on stdio
docs-rag hook Claude Code hook entry point (auto re-index)Every command accepts --config <path>.
Test retrieval from the terminal
docs-rag search "how do we handle authentication"You'll see the matching sections, each with the reason it was returned:
[semantic 0.712] reference/auth.md › Auth > How the client refreshes the token
[both 0.688] decisions/2026-01-session-length.md › Session length [2026-01-14]
[exact match] reference/errors.md › Error codes > ERR_TOKEN_EXPIREDThis is exactly the retrieval the MCP client will use — verify it here first.
To skip folders for a single query, pass --exclude (comma-separated path
fragments, case-insensitive):
docs-rag search "how do we handle auth" --exclude archive,draftsThe MCP tool exposes the same thing as an optional exclude array on
search_notes, so you can ask "search, but ignore the archive folder" in
conversation.
Some folders (archive, plans by default — see defaultExclude) are skipped
on every query, not just when you pass --exclude. To search them anyway for
one query, pass --all (CLI) or searchAll: true (the tool parameter). The
same flag re-includes docs marked superseded/archived in frontmatter, which
are hidden from default search too.
Two retrieval tracks, and the labels
Dense embeddings are weak on exactly the things a spec is full of: acronyms, error codes, function names, version numbers. So each query runs two tracks and merges them.
The semantic track ranks every chunk by cosine similarity and applies the thresholds.
The lexical track is a full-text (FTS5) search restricted to the rare terms of your query. "Rare" is measured against your own index: a term qualifies if it appears in at most
max(5, lexicalMaxDocFreq × total chunks)chunks, and in no more than half of them. Running FTS on every word would flood the results with matches on common terms; the rarity gate is what keeps it precise.
The label on each hit tells you which track put it there:
label | meaning |
| found by meaning, cosine score |
| found by both tracks — the strongest signal |
| lexical only. No score shown on purpose: the cosine value is not the reason this hit is here, and printing it would suggest otherwise |
Lexical-only hits are capped (2 slots) and always come after the semantic ones, so a rare-term match can add to the answer but never take it over.
Embedding models
Any embedding model available on Ollama works — set embedModel in the config.
bge-m3 is the default and the thresholds ship tuned for it.
Changing embedModel now forces a full rebuild. The index records which
model built it; opening it with a different one is refused rather than silently
scored against incompatible vectors. Earlier versions would have quietly mixed
them and returned wrong results with no error at all.
Some models need a task prefix on the input (nomic-embed-text wants
search_query: / search_document:). Those live in a small registry, applied
for you. If you pick a model the registry doesn't know, docs-rag index says
so — the search still works, but nobody has verified the prefix convention or
the thresholds for it.
The calibration report
At the end of every index run you get a line like:
Calibration: background noise p99 = 0.421 over 500 random pairs -> suggested minScore 0.45 (in use: 0.55, from the model registry).It samples random chunk pairs from your own corpus, which are by definition unrelated, and reports the similarity score they reach anyway. That is the model's noise floor: anything scoring below it is indistinguishable from two documents that have nothing to do with each other.
Use it as a lower bound, not a setting to copy. If the suggested value is far
above your configured minScore, your threshold is admitting noise. If it's
far below, you're free to be stricter. A configured value always wins — the
report never overwrites your choice, it only tells you what it measured.
One instance per project (recommended)
You usually want a separate knowledge base per project. You do not need a copy of this tool per project — install it once, then give each project its own config, registered at project scope:
docs-rag scaffold /path/to/some-project # asks a few questions (or pass flags)For that project it writes:
some-project/.rag/config.json— its config (vaultPathis the project root; the index lands in.rag/index.db, next to it). Re-running scaffold on an already scaffolded project preserves this file: tunedpathBoostsand thresholds survive, and only keys you passed explicitly as flags on that run are overwritten.some-project/.mcp.json— a project-scoped MCP registration. Existing servers in it are preserved. Because the command carries no machine-specific path, this file is committable: whoever clones the repo gets search over its docs without installing anything by hand.appends
.rag/index.db*and the legacy.rag/index.json*to the project's.gitignore.with
--hook:some-project/.claude/settings.local.json, a Claude Code hook that re-indexes in the background after every markdown edit (see below).
Then:
docs-rag index --config /path/to/some-project/.rag/config.jsonNon-interactive, scriptable across many repos:
docs-rag scaffold /path/to/proj --name proj-docs --include "**/docs/**/*.md" \
--desc "What's in this project's docs" --hook --yesHow --config is resolved
Every command accepts --config. A config file "carries" its own index (a
relative indexPath resolves next to the config file), so instances never
interfere. Resolution order:
An absolute path always wins.
CLAUDE_PROJECT_DIR, which Claude Code sets to the project root in the environment of the servers and hooks it spawns.Walking up from the working directory to the first ancestor where the path exists. This is what makes
--config .rag/config.jsonwork on clients that set no environment variable of their own.Otherwise the working directory.
With no --config at all, the same walk-up looks for .rag/config.json, so
running docs-rag search "…" anywhere inside a scaffolded project just works.
Do not write ${CLAUDE_PROJECT_DIR} into .mcp.json args yourself: Claude
Code does not expand variables there, so it would be passed through literally.
Wiring it into an MCP client
Claude Code
Project scope is what docs-rag scaffold sets up. For a knowledge base you
want in every session instead, register at user scope:
claude mcp add work-docs -s user -- npx -y docs-rag-mcp serve --config ~/vaults/work.jsonCheck it's connected with claude mcp list, then ask something like
"search_notes: why did we choose X?".
Codex CLI, Cursor, Zed, and other MCP clients
The server is plain stdio MCP, so any client that speaks the protocol can run
it. In Codex CLI's ~/.codex/config.toml:
[mcp_servers.docs-search]
command = "npx"
args = ["-y", "docs-rag-mcp", "serve", "--config", "/absolute/path/to/.rag/config.json"]Cursor and Zed take the same command and args in their own MCP settings.
An absolute --config is the form to use here, because it depends on
nothing. A relative one also works via the directory walk-up described above —
but that path is verified by construction and unit tests, not tested
against those clients. If you run it on one of them, a report is welcome.
Give each instance its own serverName and toolDescription: the description
is what the model reads to decide whether to call the tool at all, so describe
what is in this knowledge base, not what the tool does.
Keeping the index fresh
The index is a build artifact: index.db, a SQLite database in WAL mode, with
its -wal and -shm sidecars. After editing docs, re-run docs-rag index —
it's incremental, only re-embedding files whose mtime changed. A running MCP
server picks up every re-index automatically, no restart needed. A stale index
answers with stale content — worse than a miss.
Automatic re-index (Claude Code only, opt-in)
Scaffold with --hook and a Claude Code
PostToolUse hook re-runs the
incremental indexer in the background whenever Claude writes or edits a
markdown file in that project — coalesced to at most one run every 30 seconds,
with no edit left behind. Edits made outside Claude Code still need a manual
run.
The hook lives in the project's .claude/settings.local.json (personal, not
committed); delete the PostToolUse entry to disable it. If a background run
can't work — most often because Ollama isn't running — you get one warning per
episode in the session, not one per save, and the details land in
.rag/hook.log. Other MCP clients don't run Claude Code hooks: there, re-index
by hand.
Upgrading an existing index
The first docs-rag index after upgrading is a one-time full re-embed, not
the usual sub-second no-op: the schema gained the model-identity table and the
FTS5 table, and old vectors can't be carried across. With the hook enabled it
starts in the background on your first edit, so expect that first run to take
minutes rather than a second.
Upgrading from the original index.json: it's renamed to index.json.bak and
rebuilt from scratch. Delete the .bak once you're happy with the result.
Configuration reference
Field | Default | Notes |
| — (required) | folder to index; absolute, or relative to the config file |
|
| |
|
| |
|
| |
|
| any Ollama embedding model; changing it forces a rebuild |
|
| relative to the config file; SQLite (WAL mode) |
|
| default number of results |
|
| MCP server name shown in the client |
| generic | tell the model what this knowledge base contains |
Tuning retrieval
Five optional fields control what comes back (defaults in parentheses):
defaultExclude(["archive", "plans"]) — path substrings skipped on every query. Callers opt back in per-query withsearchAll: true(CLI:--all); the same flag re-includes docs markedsuperseded/archivedin frontmatter.minScore(0.55) — absolute cosine floor. Below it, results are dropped and the tool answers "No relevant results" with the best rejected score, instead of returning noise. See the calibration report above for how to pick it.relativeCutoff(0.88) — drops results scoring below this fraction of the best surviving score.lexicalMaxDocFreq(0.01) — how rare a query term must be to trigger the lexical track: it qualifies if it appears in at mostmax(5, ratio × total chunks)chunks, and never if it appears in more than half the corpus. Raise it to let more terms through (more exact matches, more noise); lower it to reserve the track for genuinely unusual identifiers.pathBoosts({}) — ordering-only multipliers by path substring, e.g.{"reference/": 1.15, "decisions/": 1.1}to prefer curated docs over notes. Boosts never override the thresholds and never change the reported score.
minScore and relativeCutoff ship tuned for bge-m3. On another model, run
docs-rag index and read the calibration line before trusting the defaults.
How it works
Roughly 2,000 lines of TypeScript, no build magic, no framework. The retrieval path proper — chunking, embedding, ranking — is about 500 of them, and you can read it end to end.
src/chunk.ts— splits markdown by heading, keeps the heading breadcrumb, collapses code fences out of the embedded text, splits oversized sections.src/frontmatter.ts— parsesstatus/date/superseded_byand strips them from the indexed text.src/embed.ts+src/models.ts— Ollama's/api/embed, plus the per-model prefix registry.src/store.ts— SQLite schema, versioning, model identity, FTS5 mirror.src/index-docs.ts— walks the vault, chunks, embeds, writes incrementally.src/calibrate.ts— the noise-floor measurement behind the suggestedminScore.src/lexical.ts— the rarity gate: which query terms deserve exact search.src/search.ts— embeds the query, ranks by cosine, applies thresholds, excludes and boosts, merges the lexical track.src/server.ts— the MCP server exposingsearch_notesover stdio.src/scaffold.ts— per-project instance generator.src/hook.ts— the auto re-index hook (debounce, locking, failure notices).src/cli.ts— thedocs-ragexecutable; a subcommand table and nothing else.
Deliberately out of scope
Reciprocal-rank fusion for the two tracks. RRF discards the absolute score, and the absolute score is what
minScore— the "I don't know" threshold — is built on. Keeping the tracks separate keeps that promise intact.Multiple vaults in a single instance (use multiple configs instead).
Pluggable embedding providers — Ollama only, to keep the local-only promise.
A human-facing UI, collaboration, multi-document synthesis. This exists so the agent working on your code knows your decisions, not as a place your team reads docs.
License
MIT — see LICENSE.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables managing and searching markdown notes with semantic search, question answering, and note generation, and provides an MCP server for GitHub Copilot integration.4
- AlicenseAqualityDmaintenanceTurns a local folder of notes and documents into a searchable knowledge base for AI assistants via MCP, enabling semantic search, reading, and adding notes entirely on-device.49MIT
- FlicenseNot gradedqualityCmaintenanceEnables AI agents to list, search, read, and append to Markdown notes through MCP tool calls, making it easy to interact with a second brain folder.
- AlicenseNot gradedqualityBmaintenanceEnables Claude Code to search and retrieve from a local knowledge base of markdown notes using hybrid semantic+keyword search, keeping data entirely offline.13MIT
Related MCP Connectors
Serve a folder of Markdown notes as an MCP server: hybrid search, reading, and sourced answers.
Search, read, and write your Apple Notes from ChatGPT/Claude via a local Mac agent + MCP relay.
Search and reason over your Obsidian-style Markdown vault, right from ChatGPT.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/andreaselmi/docs-rag-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server