Skip to main content
Glama
mcp-docqa-server

mcp-docqa-server

by saivarun161

mcp-docqa-server

CI Python 3.10+ License: MIT

An MCP server that gives any AI client semantic search over a document corpus — the retrieval half of a RAG pipeline, shipped as reusable infrastructure. Point any MCP host at it and the model can search, read, and cite your documents autonomously; generation stays in the client, retrieval lives here.

"What does the corpus say about the hour-1 sepsis bundle?"
        │
        ▼                      MCP (stdio / HTTP)
┌──────────────┐   search_documents("hour-1 sepsis bundle", k=5)   ┌───────────────┐
│  AI client   │ ────────────────────────────────────────────────► │ docqa server  │
│  via any     │ ◄──────────────────────────────────────────────── │ embed → ANN   │
│  MCP host    │     top-k chunks + titles, urls, scores           │ search → rank │
└──────────────┘                                                   └──────┬────────┘
                                                                          │
                                                          SQLite (embedded, exact)
                                                          or Postgres + pgvector (HNSW)

Why this exists

Most RAG demos hard-wire retrieval into one chatbot. Exposing retrieval through MCP inverts that: index once, query from anywhere — a desktop AI assistant, an IDE agent, a CI job, your own client. The server is deliberately boring infrastructure: typed tools, two interchangeable storage backends, pluggable embeddings, an eval harness, and loud failures where silent ones usually live (see Design decisions).

Related MCP server: brkt-gdrive-mcp

Features

  • Four typed MCP tools — search_documents, fetch_document, corpus_stats, ping — with docstrings written for the calling model, because tool descriptions are the interface.

  • Hybrid retrieval by default: semantic (vector) and keyword (BM25 / Postgres FTS) search fused with Reciprocal Rank Fusion, so exact terms embeddings blur — identifiers, drug names, error codes — still land. Callers can force vector or lexical per query.

  • Two vector stores, one contract: embedded SQLite (zero infrastructure, exact brute-force cosine + FTS5) and Postgres + pgvector (HNSW index + GIN full-text, production posture). Both pass the same behavioral test battery.

  • Pluggable embeddings: OpenAI text-embedding-3-small, or a deterministic keyless hashing embedder so a fresh clone works with no API key, no database, no network.

  • Idempotent ingestion: per-document content hashes mean re-runs skip unchanged docs — nothing gets re-embedded (or re-billed) by accident.

  • Corpus fetchers for PubMed abstracts (NCBI E-utilities), arXiv papers (Atom API), and any generic RSS 2.0 / Atom 1.0 feed (blogs, changelogs, EDGAR) — all keyless and rate-limit aware, behind one --source switch.

  • Retrieval eval harness: recall@k and MRR against a labeled testset, usable as a CI quality gate (docqa-eval --min-recall5 0.9).

  • stdio + Streamable HTTP transports, Dockerfile included.

  • CI that means it: lint, unit tests, a real MCP client round-trip over stdio, and an integration job against a live pgvector service container that ends by gating on retrieval recall.

Quickstart — 60 seconds, no API key

git clone https://github.com/saivarun161/mcp-docqa-server.git
cd mcp-docqa-server
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

docqa-ingest index --sample   # bundle of 12 healthcare docs -> SQLite index
docqa-eval                    # recall@1/3/5 + MRR against the bundled testset

You now have a working index at data/index.db. Talk to it over real MCP with the Inspector:

npx @modelcontextprotocol/inspector .venv/bin/docqa-server

Wire it into your MCP host

  1. Open your MCP host's config file — each host documents its own location (commonly under the host's application-support directory).

  2. Add the block from mcp_host_config.example.json with absolute paths — hosts launch servers from their own working directory, so relative paths break.

  3. Fully restart the host and ask: "Search the docqa corpus: what counts as stage 2 hypertension?"

The model will call search_documents, read the chunks, and answer with sources.

A real corpus

Index a few hundred abstracts on any topic from PubMed or arXiv (keyless, public data):

# medical literature (PubMed, the default source)
docqa-ingest fetch --query "semaglutide cardiovascular outcomes" --max-docs 200
# or CS/ML papers (arXiv)
docqa-ingest fetch --source arxiv --query "retrieval augmented generation" --max-docs 200
# or any RSS/Atom feed — the query is the feed URL (blogs, changelogs, EDGAR filings)
docqa-ingest fetch --source feed --query "https://export.arxiv.org/rss/cs.IR"
docqa-ingest index --corpus data/corpus.jsonl
docqa-ingest stats

Any JSONL with id, title, url, text fields works, so beyond the built-in adapters you can point it at your own notes. The generic feed adapter auto-detects RSS 2.0 vs Atom 1.0 and strips HTML from entry bodies.

Production posture

Semantic embeddings — put an OpenAI key in .env (see .env.example) and re-index; DOCQA_EMBEDDINGS=auto picks it up:

pip install -e ".[openai]"
docqa-ingest index --corpus data/corpus.jsonl --force

Postgres + pgvector — vectors move into an HNSW-indexed table; search runs inside the database:

docker compose up -d        # pgvector/pgvector:pg16 with the extension enabled
export DOCQA_STORE=pgvector DATABASE_URL=postgresql://docqa:docqa@localhost:5432/docqa
pip install -e ".[pg]"
docqa-ingest index --sample

HTTP transport — for network-reachable deployments instead of stdio:

docqa-server --transport http --host 0.0.0.0 --port 8000
# or containerized:
docker build -t mcp-docqa-server . && docker run -p 8000:8000 mcp-docqa-server

MCP tools

Tool

Arguments

Returns

search_documents

query, k=5, mode="hybrid"

top-k chunks with doc_id, title, url, text, score

fetch_document

doc_id

the full source document

corpus_stats

—

doc/chunk counts, backend, embedder that built the index

ping

—

"pong" (connectivity check)

mode selects the retrieval strategy: hybrid (default) fuses both legs, vector is semantic-only (best for paraphrased/conceptual questions), lexical is keyword-only (best when an exact term must appear).

Retrieval quality

docqa-eval retrieves for every testset question and reports where the expected document ranked:

Retrieval eval — 12 questions, k=5, mode=hybrid
store=sqlite  embedder=hash-v1-512

  [rank 1] What blood pressure reading counts as stage 2 hypertension?  (expects sample-001)
  ...
recall@1=1.00  recall@3=1.00  recall@5=1.00  MRR=1.00

Pass --mode vector|lexical|hybrid to compare retrieval strategies on the same testset. The bundled corpus is small and topically distinct, so every mode scores perfectly — that run proves the plumbing. The interesting experiments start when you index a few hundred PubMed abstracts and compare hash vs openai embeddings, or vector vs hybrid, on your own testset; CI runs the eval against a live pgvector container and fails the build if recall@5 drops below 0.9.

Design decisions

  • Hybrid retrieval fuses with RRF, not score-mixing. Vector cosine and BM25 live on incomparable scales, so blending their raw scores needs fragile per-corpus tuning. Reciprocal Rank Fusion instead combines ranks — each chunk scores Σ 1/(60 + rank) over the legs it appears in — which needs no calibration and rewards chunks both legs agree on. Each leg runs in its own engine (NumPy cosine / SQLite FTS5 / Postgres GIN); the retriever pulls a deeper candidate pool from each, then fuses.

  • Embedder identity is persisted and enforced. Vectors from different embedders live in unrelated spaces; querying an OpenAI-built index with hash vectors doesn't error mathematically — it just returns garbage. The store records which embedder built it and the retriever refuses a mismatch with an actionable message. Silent failure → loud failure.

  • Brute force is a feature at SQLite scale. Exact cosine over a few thousand chunks is milliseconds with NumPy and has zero recall loss; ANN indexes buy speed at scale, not correctness. The pgvector backend adds HNSW when the corpus outgrows brute force.

  • Chunks carry their title. Each chunk is prefixed with its document title before embedding, so a chunk ripped out of context still knows what it's about.

  • One behavioral battery, two backends. The SQLite and pgvector stores pass the identical test suite (tests/store_suite.py), which is what "interchangeable" actually means.

  • The MCP layer is tested with a real MCP client. CI spawns the server over stdio and drives it with an mcp.ClientSession — the same handshake a real MCP host performs — not by calling Python functions directly.

  • Public data only. PubMed abstracts and original sample docs. Never index proprietary or employer documents into a demo corpus.

Project structure

src/docqa/
├── server.py            # FastMCP server + tool definitions
├── retriever.py         # hybrid/vector/lexical modes + RRF fusion, embedder guard
├── embeddings.py        # OpenAIEmbedder | HashingEmbedder (keyless fallback)
├── chunking.py          # word windows with overlap
├── config.py            # env-driven settings (.env aware)
├── store/
│   ├── base.py          # VectorStore contract (vector + lexical) + embedder guard
│   ├── sqlite_store.py  # embedded: brute-force cosine + FTS5 BM25
│   └── pgvector_store.py# Postgres: pgvector HNSW + GIN full-text
├── ingest/
│   ├── pubmed.py        # NCBI E-utilities fetcher (keyless, rate-limited)
│   ├── arxiv.py         # arXiv Atom fetcher (keyless; parser is unit-tested)
│   ├── feed.py          # generic RSS 2.0 / Atom 1.0 reader (keyless; parser unit-tested)
│   ├── pipeline.py      # chunk -> embed -> upsert, content-hash idempotent
│   └── cli.py           # docqa-ingest fetch | index | stats
├── eval/run_eval.py     # recall@k + MRR, CI-gateable
└── data/                # bundled 12-doc sample corpus + labeled testset
tests/                   # unit + store battery + MCP stdio round-trip
.github/workflows/ci.yml # lint, tests, live pgvector integration + recall gate

Roadmap

  • MCP tools over stdio + Streamable HTTP

  • SQLite and pgvector backends behind one contract

  • Idempotent ingestion + PubMed fetcher

  • Eval harness with CI recall gate

  • Hybrid retrieval (BM25 + vector, reciprocal rank fusion)

  • Cross-encoder reranking stage

  • Corpus adapters: PubMed + arXiv

  • Generic RSS 2.0 / Atom 1.0 feed adapter (reads EDGAR Atom feeds, blogs, changelogs)

  • Dedicated EDGAR adapter (CIK/form filtering on top of the feed reader)

  • Bearer-token auth for the HTTP transport

License

MIT — see LICENSE.

Built by Varun Kammadanam — backend + GenAI engineer (Java, Python, AWS, RAG systems).

Available Tools

4 tools
corpus_statsA

Describe the indexed corpus: document/chunk counts, storage backend, and which embedding model built the index.

Call this first if you are unsure whether the corpus is relevant to the user's question or whether anything has been indexed at all.

Inputs: no named parameters.

fetch_documentA

Fetch one full source document by its id.

Use this after search_documents to read a promising source in full instead of reasoning from a chunk. Returns id, title, url, and the complete text.

Args: doc_id: The document id exactly as returned by search_documents.

Inputs: doc_id (required).

pingA

Health check. Returns 'pong' to confirm the docqa server is reachable.

Use this to verify the connection between the host and this server.

Inputs: no named parameters. Returns: result

search_documentsA

Search the indexed document corpus.

Returns the k best-matching text chunks, each with its source metadata: doc_id, chunk_index, title, url, the chunk text, and a relevance score (higher is better; scores are comparable within one response, not across modes). Results may include multiple chunks from the same document. Use fetch_document with a result's doc_id to read the full source document.

Args: query: A natural-language question or search phrase. k: How many chunks to return (default 5, max 25). mode: 'hybrid' (default) fuses semantic and keyword search — best for most questions. 'vector' is semantic-only — best for paraphrased or conceptual questions. 'lexical' is keyword-only — best when the exact term must appear (identifiers, drug names, error codes).

Inputs: query (required), k, mode. Returns: result

Tool Schema Changelog

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

Loading tool schema changelog…

    TDQS

    A4.6/5.0

    Scored across 4 tools

    Disambiguation5/5

    Each tool serves a unique, clearly defined purpose: ping for health check, corpus_stats for corpus metadata, search_documents for querying, and fetch_document for retrieving full documents. No functional overlap exists.

    Naming Consistency4/5

    Three tools follow the verb_noun pattern (corpus_stats, fetch_document, search_documents), while 'ping' is a standalone verb. This minor deviation is acceptable as ping is a standard health check command.

    Tool Count5/5

    With 4 tools, the server is well-scoped for a document QA system: health check, corpus statistics, search, and document retrieval. Each tool is essential and none are extraneous.

    Completeness4/5

    The tool set covers the core workflow of searching and fetching documents. A minor gap is the absence of a tool to list or browse documents, but for the stated purpose of answering questions via search, it is sufficient.

    Maintenance

    ActivityInactive
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      An MCP server that indexes documents and serves relevant context to LLMs via Retrieval Augmented Generation (RAG).
      30 npm
      36
      MIT
    • F
      license
      Not graded
      quality
      D
      maintenance
      A Retrieval Augmented Generation MCP server that ingests documents into a local vector database and enables semantic search queries.
      9
      -