Skip to main content
Glama
regankight

RAG Search MCP Server

by regankight

RAG Search MCP Server

A small MCP server with one tool, search_documents(query, top_k), that lets an agent search a local set of text files by meaning.

There are no external services, API keys, or databases. Clone it, install the dependencies, point an MCP client at it, and it works against the bundled sample corpus.

Why I built it

An agent can only use the tools it's given. I'd already built retrieval pipelines, so this project covers the other half: making retrieval something an agent can call, through the protocol agent clients (Claude Desktop, Claude Code, and others) already speak.

The search code comes from my pdf-rag-from-scratch project: tokenizer-aware chunking, local embeddings, cosine-similarity ranking. I re-pointed it at a folder of .txt files instead of a PDF, so nobody has to supply their own corpus to try it.

Related MCP server: rag_mcp

What it does

query
  ↓
embed the query (same model that embedded the corpus)
  ↓
cosine similarity against every chunk
  ↓
top_k chunks, best first, each with its source file and score

It only retrieves. It doesn't write an answer; that's the agent's job (see Design decisions).

Install and run

Needs Python 3.10 or newer (the MCP SDK's minimum). I've only run it on 3.14.

git clone https://github.com/regankight/rag-search-mcp-server.git
cd rag-search-mcp-server
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

Run it directly (MCP clients talk to it over stdio):

python mcp_server.py

To use it from an MCP client such as Claude Desktop or Claude Code, point the client's config at that command:

{
  "mcpServers": {
    "rag-search": {
      "command": "/absolute/path/to/rag-search-mcp-server/.venv/bin/python",
      "args": ["/absolute/path/to/rag-search-mcp-server/mcp_server.py"]
    }
  }
}

The first run downloads the embedding model (all-MiniLM-L6-v2, about 90MB) from Hugging Face and caches it. The server loads the model and builds the index in a background thread as soon as it starts, so it answers the client's handshake in about 0.3 seconds instead of waiting for the model. A search that arrives before the index is ready waits for it.

Example

Searching for "catching quality getting worse over time before users complain" returns (text shortened):

[
  {
    "chunk_id": "chunk_0002",
    "source": "observability.txt",
    "text": "Observability for a language model application means being able to see...",
    "score": 0.3443
  },
  {
    "chunk_id": "chunk_0000",
    "source": "evaluation_frameworks.txt",
    "text": "Evaluating a retrieval system means measuring whether the documents it...",
    "score": 0.192
  }
]

The observability passage wins by a wide margin. It talks about noticing "quality drift before a user complains", and shares only two generic words with the query (quality, before), so the match comes from meaning, not wording. These numbers come from a real run on the bundled corpus; scores shift slightly with the embedding model.

Called from Claude Code

Claude Code calling the search_documents tool over MCP and showing the raw result with source file and score

Claude Code calls search_documents through the MCP server. The raw result includes the source file and score for each passage, and the answer is built from that passage.

Design decisions

Bundled sample corpus. A server only proves something if a reviewer can run it. Asking them to bring their own documents first is friction most people won't push through. sample_docs/ has five short passages on RAG-related topics (retrieval, hybrid search, observability, evaluation, embeddings) so a fresh clone gives a correct result straight away. To search your own files, point DEFAULT_DOCS_DIR in search.py at a directory of .txt files.

Self-written samples, not a real dataset. The benchmark PDF used in pdf-rag-from-scratch isn't committed there (data/*.pdf is gitignored), and its redistribution license was never checked. I wrote the samples myself instead of carrying content with unclear reuse rights into a second public repo. Their only job is to show that search works.

Retrieval only, no generation. Adding an LLM call would make this a smaller copy of pdf-rag-from-scratch. The point here is the MCP layer: giving an agent a search tool it can call.

In-memory index, no vector database. Five short files make a handful of chunks, so comparing the query against every chunk is simpler and fast enough. A vector database would solve a scaling problem this project doesn't have. The index is built once per server process and kept in memory.

Copied search code, not imported. Depending on pdf-rag-from-scratch (submodule, or pip install from a git URL) would mean cloning two repos to run one. Copying the small amount of code needed keeps this repo standalone. The cost is that the copies can drift apart, which is fine for a demo and not something I'd do for shared production code.

mcp>=2,<3 is pinned. The MCP Python SDK made a breaking change in v2.0.0 (2026-07-28): the server class moved from mcp.server.fastmcp.FastMCP to mcp.server.MCPServer, and the old import path was removed, not deprecated. Tutorials written before then fail on a fresh install with ModuleNotFoundError. This repo uses the v2 API (from mcp.server import MCPServer, with an explicit mcp.run(transport="stdio")) and pins the major version so it doesn't break the same way when the SDK changes again.

Index built in a background thread, behind a lock. Loading the model takes seconds, and the first run also downloads it. Doing that inside the first tool call risks a client timeout on the call a reviewer is most likely to make first. Doing it before the server starts would delay the handshake. So python mcp_server.py starts the build in the background and serves immediately, and an early search waits on the same build. Even importing the embedding library is slow, because sentence-transformers pulls in torch (about 2.5s), so that import is deferred into the build too. A test checks that importing the server never loads torch. The lock is there because the SDK runs sync tool functions on worker threads: without it, two searches arriving together would each load the model. A test starts eight threads at once and asserts the index is built exactly once.

Blank queries are an error. Embedding an empty string still produces a vector, so every passage gets ranked and returned with a near-zero score that looks like a result but isn't one. The tool rejects blank queries instead. The error is raised as the SDK's ToolError, because an ordinary exception is hidden from the client (it would see only "Error executing tool search_documents"), and the agent wouldn't learn what to fix. A test checks the message from the client's side over real stdio. The tool description also states two behaviors the schema can't show: top_k is clamped to between 1 and the corpus size, and queries over the embedding model's 256-token limit are truncated.

Results include the source file. A passage with only a positional chunk_id can't be cited or checked, and a retrieval tool exists so an agent can ground an answer in something. Files are chunked separately, so each chunk comes from exactly one file, and that filename is returned with the text.

search() is separate from the decorated tool. search_documents is a thin wrapper around search(). Tests call the plain function, so they don't depend on the SDK's decorator internals. The wrapper's only other job is converting a plain error into a ToolError.

Three kinds of tests. Fast tests use a stub model (fixed vectors keyed by exact text), so ranking, corpus loading, and the search logic run in milliseconds without loading a model. Chunking tests use the real embedding model's tokenizer, because the chunker has to agree with that tokenizer's limits and a mock could pass while the real one truncates. Slower end-to-end tests build the index over the real corpus and start the server as a subprocess, talking to it with an MCP client over real stdio. That covers tool registration and the protocol layer, which the unit tests skip and which the SDK's July 2026 rename actually broke.

Project structure

rag-search-mcp-server/
├── mcp_server.py       # the MCP server: index cache and the search_documents tool
├── search.py           # chunking, embedding, cosine-similarity ranking (adapted from pdf-rag-from-scratch)
├── sample_docs/        # five short passages the demo searches by default
├── tests/
│   └── test_search.py
├── requirements.txt
├── pytest.ini
├── LICENSE
└── README.md

Running the tests

pytest tests/ -v

Scope

This is a small, single-tool server meant to show how retrieval gets exposed to an agent. It isn't a general document-search service: no authentication, one fixed corpus directory, and the index lives only as long as the server process.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables semantic search over local notes and documents using natural language queries. Supports multiple file types (Markdown, Python, HTML, JSON, CSV, text) with fast local embeddings and persistent ChromaDB vector storage.
    1
    -
  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables retrieval-augmented generation by allowing LLMs to search documents in a local folder using vector similarity, supporting multiple file formats.
    -
  • A
    license
    A
    quality
    D
    maintenance
    Enables searching and retrieving documents from a local folder to ground LLM answers in your files.
    2
    MIT