Skip to main content
Glama
mcp-rag-demo
by casi-alex

mcp-rag-demo

A small, read-only MCP server over a RAG index (Postgres + pgvector, local embeddings), with provenance on every result and a retrieval evaluation you can re-run.

The corpus is 8 sanitized Architecture Decision Records (ADRs, written in Spanish) from a WhatsApp appointment-booking assistant built on n8n. It is a deliberately small demo: the goal is to show the design decisions (fail-closed index, read-only access, untrusted-corpus handling, measurable retrieval quality), not scale.

Status: working demo. 63 tests pass; retrieval was evaluated on 18 questions (see Evaluation and its limits). Not production software.

Architecture

flowchart LR
    C["corpus/*.md<br/>8 ADRs"] -->|"chunk by headings"| I["ingest.py"]
    I -->|"local embeddings<br/>fastembed ONNX"| P[("Postgres 16<br/>+ pgvector HNSW")]
    P -->|"read-only session"| S["search.py"]
    S --> M["server.py<br/>MCP, stdio"]
    S --> A["ask.py CLI"]
    S --> E["evaluate.py<br/>hit-at-k, MRR, threshold sweep"]
    M -->|"search_docs / get_doc / list_docs"| K["MCP client<br/>Claude Desktop, Claude Code, ..."]

Piece

What it does

chunking.py

Splits Markdown by H1-H3 headings, keeps the heading path as context, respects code fences, packs long sections by paragraph (default 1000 chars).

embeddings.py

Local embeddings via fastembed. Default jinaai/jina-embeddings-v2-base-es (768 dims). No API calls.

ingest.py

Idempotent: skips files whose SHA-256 is unchanged, removes documents no longer on disk.

search.py

Cosine similarity (1 - distance), returns doc_id, chunk number, heading and score.

server.py

MCP server (official Python SDK) with three read-only tools.

evaluate.py

Runs eval/questions.jsonl, prints hit@1, hit@k, MRR and a score-threshold sweep.

Related MCP server: LTM MCP Server

Design decisions

Decision

Why

Fail closed on model changes. A fingerprint model|dim|max_chars is stored in index_meta; a mismatch aborts (re-index with --reset).

Comparing vectors from different models returns plausible-looking garbage without raising any error.

Read-only by construction. The server opens Postgres with default_transaction_read_only=on; all three tools are annotated readOnlyHint.

A tool that only reads should not be able to write even if the code is wrong. A test removes the option and fails.

get_doc reads from the DB, not the filesystem. Plus a doc_id allowlist regex.

No path traversal surface. Tested with traversal strings and SQL metacharacters.

Corpus is treated as untrusted data. Every response carries a note telling the model not to follow instructions found in fragments.

Prompt-injection from indexed documents is the main RAG-specific risk. This is a mitigation, not a guarantee.

Only ToolError messages reach the model. Other exceptions become a generic error.

Avoids leaking internals (DSN, stack traces) to the client.

Tests refuse to run on a non-*_test database.

The tests drop tables; they must never touch the demo index.

Quick start

Requirements: Python 3.10+, Docker. First run downloads the embedding model (~0.64 GB).

docker compose up -d                       # Postgres + pgvector on 127.0.0.1:5433
py -3 -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -e ".[mcp]"
python -m ragdemo.ingest --corpus corpus   # first run: add --reset if you changed model
python -m ragdemo.ask "¿Con qué algoritmo se cifran los teléfonos?" -k 3
python scripts\smoke_mcp.py                # optional: exercises the MCP server as a real client

Expected: the top hit is ADR-016-cifrado-de-campo-postgres with its heading and a score.

(On Linux/macOS use python3 -m venv .venv && source .venv/bin/activate.)

Use it from an MCP client

Add this to your client's MCP configuration (path to the venv's Python must be absolute):

{
  "mcpServers": {
    "ragdemo": {
      "command": "C:\\path\\to\\mcp-rag-demo\\.venv\\Scripts\\python.exe",
      "args": ["-m", "ragdemo.server"],
      "env": {
        "RAGDEMO_DSN": "postgresql://ragdemo:ragdemo@127.0.0.1:5433/ragdemo",
        "RAGDEMO_MIN_SCORE": "0.30"
      }
    }
  }
}

Variable

Default

Meaning

RAGDEMO_DSN

docker-compose DSN

Postgres connection.

RAGDEMO_MODEL

jinaai/jina-embeddings-v2-base-es

Must match the model used to ingest.

RAGDEMO_MIN_SCORE

unset (no filter)

Drop hits below this similarity. 0.30 is suggested from the evaluation below; it is a rough estimate, not a guarantee.

Tools: search_docs(query, k=5), get_doc(doc_id), list_docs(). The server is stdio-based: stdout is the protocol, all logs go to stderr. It exits with an error if there is no index or the model does not match.

Evaluation

python -m ragdemo.evaluate --k 3 --json eval\report-jina.json

18 questions in eval/questions.jsonl: 14 answerable (12 Spanish, 2 English) with the expected ADR, and 4 out of scope. Metrics are at document level (did an expected ADR appear in the top-k?). Result with jinaai/jina-embeddings-v2-base-es, run on 2026-11-12:

Metric

Value

hit@1 / hit@3 / MRR

1.00 / 1.00 / 1.00 (14/14)

Lowest top-1 score among answerable questions

0.435

Highest top-1 score among out-of-scope questions

0.233

How to read this honestly

  • Perfect scores on 14 questions show the pipeline works; they do not rank it against alternatives.

  • The questions were written by the corpus author after reading the ADRs, so they share vocabulary with the documents. Real users phrase things worse. A short query ("dos agentes") scored 0.382 in a manual check, well below the long-form scores.

  • The 4 out-of-scope questions are unrelated to the domain (recipes, geography, stock prices). There are no hard negatives (on-topic questions the corpus does not answer), so the 0.30 threshold is untested where it matters most.

  • Only one embedding model was evaluated.

Tests

docker compose exec db createdb -U ragdemo ragdemo_test
$env:RAGDEMO_TEST_DSN = "postgresql://ragdemo:ragdemo@127.0.0.1:5433/ragdemo_test"
pip install pytest
pytest -q          # 63 tests

Without RAGDEMO_TEST_DSN, tests that need a database are skipped (on Linux/macOS with Python < 3.13, pip install -e ".[dev]" brings an embedded Postgres via pgserver). Tests use a bag-of-words hash embedder, so they exercise the pipeline, not semantic quality.

Known limits

  • Tiny corpus (8 documents, ~70 chunks); no re-ranking, no hybrid (keyword) search.

  • Chunking is by Markdown headings with a character cap; no token-aware splitting.

  • The stdio server is tested end-to-end with the official MCP Python client: in the test suite (hash embedder) and with the real model via scripts/smoke_mcp.py (Windows, Python 3.14). It has not been verified with a desktop MCP client such as Claude Desktop.

  • The corpus was sanitized by rules plus manual review (names, phone numbers, client names). Indirect identifiers can survive any automated pass.

  • Spanish-first: the default model is Spanish-oriented; English queries work (see q13, q14) but were only lightly evaluated.

License

MIT.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Exposes document retrieval as an MCP tool, enabling LLMs to search a local vector store of markdown documents. Includes a retrieval evaluation harness to measure hit rate and MRR.
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables MCP-capable AI to perform read-only semantic search over a local document corpus stored in Postgres with pgvector, using mxbai-embed-large embeddings and optional reranking for confidence-gated results.
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables AI agents to query and manage a document knowledge base via MCP, with RAG-powered search and grounded answers with citations.
      MIT