Skip to main content
Glama
README.md
# codebase-rag-mcp

A Model Context Protocol (MCP) server that turns any local codebase into a
queryable, citation-grounded knowledge base. Built on a hybrid retriever
(dense FAISS + sparse BM25), an optional reranker, and a swappable LLM
provider (NVIDIA, Groq, OpenRouter, Gemini, or any local OpenAI-compatible
endpoint).

> **Status:** the full RAG pipeline is implemented and MCP-connected:
> GitHub/local ingestion, Tree-sitter AST-aware chunking, hybrid
> FAISS + BM25 retrieval with a cross-encoder reranker, multi-provider
> LLM generation with citations, and a six-tool stdio MCP server
> (`search_code`, `find_symbol`, `get_file_context`, `ask`,
> `analyze_impact`, `repository_summary`) with zero-config auto-indexing
> on first connect. See `DECISIONS.md` and `FLOW.md` for the full
> build history and architecture.

## How It Works

*(This section was written by querying the project's own `codebase-rag`
MCP tools against its own indexed source — `repository_summary`,
`search_code`, `find_symbol`, and `analyze_impact` — rather than from
memory. Every claim below links back to a real file and, where useful,
line range. At the time of writing, this repo's own index reported 89
indexed Python files, 685 chunks, and 668 distinct symbols across three
top-level modules: `benchmarks`, `src`, `tests`.)*

### What this project does, in plain terms

It's a "chat with your codebase" server, but built to refuse to make
things up. Point it at a repo — a GitHub URL or a local path — and it
parses every file's real syntax tree, breaks the code into
function/class/method-sized chunks with exact file/line metadata, and
indexes those chunks two different ways (keyword search and semantic
vector search). When you ask a question, it retrieves the most relevant
chunks, reranks them for actual relevance, and only then asks an LLM to
write an answer — with a hard rule that the LLM cannot introduce a
citation for a piece of code it wasn't actually shown. The whole thing
is exposed as an MCP server, so any MCP-compatible AI coding tool
(Claude Code, Claude Desktop, Cursor, etc.) can call it as a set of tools
during a normal coding session.

### Architecture — how a query flows from indexing through retrieval to a final answer

**Indexing (offline, `codebase-rag index`):**

1. **Parsing** (`src/codebase_rag_mcp/parser/`) — `parser.extractor.parse_file`
   runs each file through Tree-sitter and walks the resulting AST twice:
   once to pull out functions/classes/methods/interfaces as `ParsedSymbol`s
   (with 1-indexed line ranges — Tree-sitter's own positions are 0-indexed
   and get converted before a `ParsedSymbol` is ever built), and once more
   over the *same already-parsed tree* to extract call/import references
   used later by `analyze_impact`. A method nested in a class gets a
   *qualified* name (`"ClassName.method"`) so two classes with a
   same-named method never collide. A malformed file never aborts the
   run — syntax errors are recorded in `parse_errors` and parsing
   continues opportunistically.
2. **Chunking** (`src/codebase_rag_mcp/chunker/`) — `chunker.chunker.chunk_file`
   turns each `ParsedSymbol` into one retrievable `Chunk`, carrying
   `repo, file, symbol, type, language, start_line, end_line, content`
   plus a deterministic `id` (derived from file + symbol + start line, so
   re-indexing an unchanged file produces byte-identical chunk IDs and
   citations stay stable across re-runs). A symbol that's too large is
   split by `chunker.fallback.split_oversized_symbol` along in-span line
   boundaries into `name#part1`, `name#part2`, ... — verified directly:
   splitting a 25-line symbol at `max_chunk_lines=10` produces three
   contiguous, non-overlapping spans. A file with zero extractable
   symbols still gets one whole-file fallback chunk, so nothing is
   silently dropped.
3. **Dual indexing** (`src/codebase_rag_mcp/indexing/`) — the same chunk
   collection is indexed twice: `indexing.vector` embeds every chunk
   locally with `all-MiniLM-L6-v2` (via `langchain_huggingface
   .HuggingFaceEmbeddings`, `normalize_embeddings=True`) into a hand-rolled
   persistent FAISS `IndexFlatIP` index over L2-normalized vectors
   (`vector.faiss` + a parallel `vector_metadata.json` keyed by FAISS
   vector ID); `indexing.bm25` builds a `rank_bm25.BM25Okapi` sparse index
   over the same chunks. A `manifest.json` records the checkout root so
   `get_file_context` can later resolve citations back to real files on
   disk.

**Query time (the `search_code` / `ask` MCP tools):**

4. **Hybrid retrieval** (`src/codebase_rag_mcp/retrieval/hybrid.py`) —
   `hybrid_search` queries the BM25 and FAISS indexes independently, then
   merges the two ranked candidate lists via **Reciprocal Rank Fusion**:
   each side's rank-`r` result contributes `1 / (RRF_K + r)` to a chunk's
   merged score, summed across both sides. Every `HybridQueryResult` keeps
   its BM25 rank/score *and* vector rank/score alongside the merged score
   — never collapsed into an opaque single number — so it's possible to
   see exactly why a chunk ranked where it did. If a chunk is found by
   only one side, the other side's rank/score is left `None`, never a
   fabricated `0` (which would be indistinguishable from a genuine top
   rank). If *both* indexes are unavailable, it raises
   `NoIndexAvailableError`; if only one is missing, it degrades to
   single-source search with a warning rather than failing outright.
5. **Reranking** (`src/codebase_rag_mcp/reranker/rerank.py`) — the wide
   hybrid candidate pool is re-scored by a `CrossEncoder`
   (`cross-encoder/ms-marco-MiniLM-L-6-v2`) in a single batched
   `.predict()` call over every `(query, chunk.content)` pair, then sorted
   by that cross-encoder score and truncated to the top N. Each
   `RerankedResult` still carries the full underlying `HybridQueryResult`,
   so the entire scoring chain — BM25 rank/score → vector rank/score →
   RRF score → cross-encoder score — is inspectable end to end, not just
   the final ranking.
6. **Generation** (`src/codebase_rag_mcp/generation/`) — `generation
   .pipeline.generate_answer` builds a file/line-formatted evidence prompt
   from the reranked chunks and calls a configured LLM provider (see
   below) for a structured JSON response: an answer, a list of
   `cited_chunk_ids`, and a `has_sufficient_evidence` flag.
7. **Citations** (`src/codebase_rag_mcp/citations/attach.py`) —
   `attach_citations` turns the model's `cited_chunk_ids` into real
   `Citation` objects, and this is where the anti-fabrication guarantee
   actually lives (see below).

### What makes the chunking/retrieval approach different from naive RAG

Naive RAG over code typically splits files by a fixed character/token
window, which routinely slices a function in half and destroys the
structure an LLM would need to reason about it correctly. This project
never does that. Chunking is driven entirely by the real AST
(`parser.extractor.parse_file` → `chunker.chunker.chunk_file`): a chunk
boundary is always a real symbol boundary (a function, class, method, or
interface), confirmed directly in `chunker/models.py`'s `Chunk` model and
`chunker/fallback.py`'s oversized-symbol splitter, which — even when a
single symbol is too large for one chunk — only ever splits *within* that
symbol's own span, never across unrelated code. Every chunk and every
downstream citation therefore carries an exact `file`, `start_line`, and
`end_line`, not an approximate "somewhere in this file" pointer.

Retrieval is also intentionally hybrid rather than vector-only. A pure
embedding search is weak on exact identifiers (a function name like
`generateToken` is a token match, not really a semantic one), while a
pure keyword search misses conceptual queries ("where is authentication
handled?") that don't share vocabulary with the code. `retrieval/hybrid.py`
runs both and merges them via Reciprocal Rank Fusion instead of a single
opaque similarity score, and a real test in the repo
(`test_hybrid_search_chunk_found_by_both_outranks_chunk_found_by_one_side`)
verifies a chunk found by *both* BM25 and vector search outranks one
found by only one side — the concrete behavior RRF is there to produce.
The cross-encoder reranking stage on top of that is a second, more
expensive pass that reads the actual `(query, candidate)` pair jointly
(rather than comparing independently-embedded vectors), which is why it
runs only over the already-narrowed hybrid pool rather than the whole
index.

### The anti-fabrication mechanism

This is enforced mechanically, not just by prompt instructions. The key
design decision, visible directly in `citations/models.py`'s `Citation`
docstring: a `Citation`'s `file`, `symbol`, `start_line`, and `end_line`
are **always** copied from this project's own indexed `Chunk` metadata in
`citations/attach.py`'s `attach_citations` — **never** from anything the
LLM itself asserts. The model is only ever allowed to supply *which*
`chunk_id` it used; it cannot originate a file path or line number that
lands in a citation, because that path doesn't exist in the code.

Concretely, `attach_citations` builds a `chunk_id → candidate` lookup
from the real retrieved evidence and walks the model's `cited_chunk_ids`
against it. An ID that doesn't match anything in the actual candidate set
is **silently dropped and logged as a warning, never raised** — the code
comment is explicit that "a model over-citing or citing a stale ID is
expected, handled input, not a bug." Then, in `generation/pipeline.py`'s
`generate_answer`, the final `has_sufficient_evidence` flag is forced to
`structured.has_sufficient_evidence AND bool(citations)` — meaning even
if the model *claims* sufficient evidence while citing something that
resolved to zero real citations, the answer is downgraded to
insufficient-evidence regardless of what the model said.

I confirmed this isn't just a described intention — it's tested directly
against an adversarial case:
`test_generate_answer_drops_fabricated_citation_even_when_fake_provider_obeys_adversarial_evidence`
simulates a "compromised" model that was prompt-injected into returning
`answer="PWNED"` with a `cited_chunk_ids` list naming a chunk that never
existed. The test asserts the mechanical backstop still holds:
`result.citations == []` and `result.has_sufficient_evidence is False`,
regardless of what the model was tricked into asserting. A query with
zero retrieved candidates never even calls an LLM provider at all — it
returns the canned insufficient-evidence answer for free.

The same pattern is reused one level up for the prose narratives behind
`analyze_impact` and `repository_summary`
(`impact/explain.py:explain_impact`, `impact/summary.py
:explain_repository_summary`): the LLM's structured JSON output is
checked for any `referenced_files`/`referenced_modules` not present in
the real evidence set, and a fabrication is treated as a retry-worthy
failure — identical in kind to a JSON-schema validation error — which
rebuilds the prompt with a fabrication-specific correction and retries,
up to a configured retry budget, before moving to the next provider in
the fallback chain.

Providers themselves are chained with runtime fallback, not hardcoded to
one vendor: `generation/providers/registry.py`'s `select_providers`
returns only the providers whose credentials are actually configured, in
a fixed **NVIDIA → Groq → OpenRouter → Gemini → Local** precedence order,
and `generate_answer` walks that list, giving each provider its own JSON
retry budget before falling through to the next — raising
`AllProvidersFailedError` only once every configured provider has
failed. This degrade-gracefully behavior is real, not theoretical, in
this exact session: calling `repository_summary` against this repo's own
index returned `explanation: null` — no provider is currently configured
here, and the tool returned the deterministic structural data with a
`None` narrative instead of erroring or inventing one, exactly as the
code above says it should.

### How it's packaged and portable across MCP clients

The server ships as an installable CLI (`codebase-rag`, via `pyproject.toml`,
installable with `pipx install .` or `uvx --from .`) exposing two
subcommands — `index` and `serve` — and `codebase-rag serve` speaks MCP
over plain stdio, which is what makes it launchable by any MCP-compatible
client as a subprocess: Claude Desktop, Claude Code, Cursor, or any other
stdio-based host (a client like OpenCode that speaks the same stdio MCP
protocol can launch it the same way — a matching JSON config snippet
just isn't included in this README yet, only Claude Desktop/Code and
Cursor are).

The portability problem this closes (documented directly in
`config._resolve_index_dir`'s docstring and enforced by
`InvalidIndexDirError`) is that an MCP client controls the subprocess's
working directory, not this project — so anything that resolved
`DATA_DIR`/`INDEX_DIR` relative to `cwd` would silently point at a
different place depending on which client launched it. Instead:

- With no explicit `--index-dir`/`INDEX_DIR`, the index directory is
  keyed by a 16-hex sha256 hash of the *canonicalized* repo source, under
  an OS-appropriate `platformdirs.user_data_dir("codebase-rag")` path —
  so the same repo always resolves to the same index directory no matter
  which directory or MCP client launched the server (verified by
  `test_resolve_index_dir_same_local_repo_same_result_regardless_of_cwd`),
  and two different repos never collide
  (`test_resolve_index_dir_two_local_repos_do_not_collide`).
- An explicit `--index-dir`/`INDEX_DIR` is honored, but **must be
  absolute** — a relative value is rejected outright with
  `InvalidIndexDirError` rather than silently resolved against `cwd`,
  since `cwd` is exactly the launch-directory dependency this mechanism
  exists to remove.
- `cli/main.py`'s `serve` dispatch resolves the effective repo source,
  index directory, and `.env` file *before* importing `mcp.server` at
  all, then reloads `config` — because many indexing/generation
  submodules capture config defaults at their own import time, so
  resolving this after import would be too late for it to take effect.
- Provider keys are read from the real process environment first (so an
  MCP client's own `"env"` config block always wins) and only fall back
  to a `.env` file — never the reverse.

Full install steps and copy-pasteable per-client JSON configs are below.

## Install

Requires **Python 3.11+**.

Not yet published to PyPI — install from a clone of this repo:

```bash
# For end users: an isolated, globally-available `codebase-rag` command
git clone <this-repo-url> && cd codebase-rag-mcp
pipx install .
# ...or run it without a separate install step:
uvx --from . codebase-rag serve --repo <path-or-url>
```

```bash
# For development: editable install with dev tooling (pytest, ruff, mypy)
pip install -e ".[dev]"
```

This pulls in tree-sitter, FAISS (CPU), rank-bm25, sentence-transformers,
langchain-huggingface / langchain-community, the official MCP Python SDK,
and httpx for outbound provider calls.

## Configure

**Recommended (any packaged/installed use, including every MCP client
below):** set provider keys directly in the client's own server
`"env"` config block — see "Connect to an MCP client" below. No `.env`
file is required for this.

**For local development**, copy the example env file and fill in
whichever provider keys you have:

```bash
cp .env.example .env
# then edit .env
```

A variable already set in the real process environment (a client's
`"env"` block, a shell export) is **never** overridden by any `.env`
file, regardless of which one is loaded — see `.env.example` for the
full discovery precedence.

Recognized variables (see `.env.example` for the full list):

| Variable               | Purpose                                                        |
| ---------------------- | -------------------------------------------------------------- |
| `NVIDIA_API_KEY`       | NVIDIA NIM / build API                                         |
| `GROQ_API_KEY`         | Groq Cloud                                                     |
| `OPENROUTER_API_KEY`   | OpenRouter (multi-provider proxy)                              |
| `GEMINI_API_KEY`       | Google Gemini (optional)                                       |
| `LOCAL_MODEL_BASE_URL` | OpenAI-compatible local server (Ollama, vLLM, LM Studio, ...)  |
| `LOCAL_MODEL_NAME`     | Model name to use against the local server                     |
| `LOCAL_MODEL_API_KEY`  | Optional bearer token for the local server                     |
| `LOG_LEVEL`            | `DEBUG` / `INFO` / `WARNING` / `ERROR` (default `INFO`)        |
| `DATA_DIR`             | Where cloned corpora are staged (default `./data`)              |
| `INDEX_DIR`            | Where FAISS/BM25 artifacts persist — default is **not** a fixed path; see below. Must be absolute if set. |
| `REPO_SOURCE`          | Default repo (URL or local path) to zero-config auto-index      |
| `AUTO_INDEX`           | Set `false` to require a prebuilt index (default `true`)        |

`INDEX_DIR` defaults to a per-repo directory keyed by a hash of the
resolved repo source, under an OS-appropriate user-data path — the same
repo always resolves to the same index directory regardless of which
directory or MCP client launched the server, and two different repos
never collide. Setting `INDEX_DIR` (or `--index-dir`) always overrides
this, but the value must be absolute — a relative path is rejected
outright.

## Run

```bash
# Print the version
codebase-rag --version

# Index a repo once (optional -- `serve` will also auto-index on first
# connect if no index exists yet)
codebase-rag index https://github.com/some-org/some-repo
# ...or a local path:
codebase-rag index /path/to/local/repo

# Boot the MCP server over stdio
codebase-rag serve --repo /path/to/local/repo
```

## Connect to an MCP client

`codebase-rag serve` speaks MCP over stdio, so any MCP-compatible client
can launch it as a subprocess. Every snippet below passes `--repo`
explicitly rather than relying on zero-config `cwd`-based detection:
a client's subprocess launch directory is that client's own choice, not
something this project controls, so an explicit `--repo` is the one
setting that is *always* correct regardless of it (see DECISIONS.md
D-027). If you've confirmed a specific client happens to launch with
`cwd` at your project root, `--repo` can be dropped as a convenience —
just never as a requirement.

**Claude Desktop** (`claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "codebase-rag": {
      "command": "codebase-rag",
      "args": ["serve", "--repo", "/absolute/path/to/your/repo"],
      "env": {
        "GROQ_API_KEY": "your-key-here"
      }
    }
  }
}
```

**Claude Code** (project-level `.mcp.json`, or `claude mcp add`):

```json
{
  "mcpServers": {
    "codebase-rag": {
      "command": "codebase-rag",
      "args": ["serve", "--repo", "/absolute/path/to/your/repo"],
      "env": {
        "GROQ_API_KEY": "your-key-here"
      }
    }
  }
}
```

**Cursor** (`~/.cursor/mcp.json`, or project-level `.cursor/mcp.json`):

```json
{
  "mcpServers": {
    "codebase-rag": {
      "command": "codebase-rag",
      "args": ["serve", "--repo", "/absolute/path/to/your/repo"],
      "env": {
        "GROQ_API_KEY": "your-key-here"
      }
    }
  }
}
```

If `codebase-rag` isn't on the client's `PATH` (common for a GUI app
that doesn't inherit your shell profile), use its full path from
`which codebase-rag` as `"command"` instead.

## Develop

```bash
ruff check .            # lint
ruff format --check .   # format check
mypy                    # type-check
pytest                  # tests
```

A preconfigured GitHub Actions workflow at `.github/workflows/ci.yml`
runs all four on every push.

## Layout

```
src/codebase_rag_mcp/
  config.py                  # env/config resolution -- provider keys, INDEX_DIR/DATA_DIR/.env discovery
  cli/main.py                # `codebase-rag` entrypoint (index / serve subcommands)
  mcp/server.py               # stdio MCP server: 6 tools + zero-config auto-indexing
  ingestion/                 # GitHub/local repo loading, file filtering, language detection
  parser/                     # Tree-sitter AST extraction
  chunker/                    # AST-aware chunking (+ oversized-symbol fallback splitting)
  indexing/
    vector.py                # FAISS dense index
    bm25.py                  # rank-bm25 sparse index
    references.py            # symbol reference/import index (for analyze_impact)
    cache.py                 # incremental-indexing chunk cache
  retrieval/                 # hybrid BM25 + vector retrieval (Reciprocal Rank Fusion)
  reranker/                  # cross-encoder reranking of the hybrid candidate pool
  generation/
    providers/               # NVIDIA / Groq / OpenRouter / Gemini / local -- fallback chain
  citations/                 # chunk -> file/line citation formatting
  impact/                    # symbol lookup, reference analysis, analyze_impact, repository_summary
```

## License

MIT. See [`LICENSE`](./LICENSE).