[03]
DOCCHAT RAG MCP
█ TIER A · YEAR 2025 · STATUS: LIVE · LANGUAGES: PYTHON
Hand-written RAG with source attribution, shipped as an MCP server
[FIG. 1] MISSION
I write RAG pipelines by hand, without a framework, when control over chunking and attribution matters more than a fast start — and I design systems that run entirely locally, where data never leaves the machine. Claude Desktop can't search your private documents and uploading sensitive files to the cloud is a non-starter, so this RAG backs every answer with its source down to the file, page and character range. PyMuPDF and python-docx cut documents along natural boundaries with a heading→paragraph→sentence priority system (1000 chars, 200 overlap) instead of blindly slicing every N characters; sentence-transformers computes MiniLM embeddings locally, and cosine retrieval returns top-k with attribution. I keep retrieval separated from the model, so swapping the LLM never touches the pipeline. I ship the result as an MCP server, a tool that plugs into someone else's client: the official low-level SDK, six tools with JSON Schema over STDIO transport, the initialize + tools/list handshake verified end-to-end. I also shipped the same core as a free Streamlit app (LangChain + Qdrant + Ollama). Zero API calls by default — everything runs on my own hardware.
[FIG. 2] ARCHITECTURE
hover a block to see its description
[FIG. 3] CHALLENGES
[+][CH-01]
The repo looked green but didn't start: five files imported the src/models package — the Document, DocumentChunk, SearchResult dataclasses — which simply wasn't in the repository, so python -m src.mcp_server_official died on the first import. The root cause was a .gitignore pattern: models/ was meant to ignore ML model caches but swallowed the source-code directory on the first git add (a second pattern, test_*.py, ate the tests). I rebuilt the whole package 'from the contract' — reading every usage site and reconstructing the fields and methods the rest of the code expected — anchored the patterns (/models/) and added an import test to the smoke suite so this class of bug can't slip through again. Ghost dependencies went out with it: fastmcp, psutil and watchdog declared without a single import, while the genuinely imported pydantic-settings finally entered the dependency list.
[+][CH-02]
Mixed PDFs and DOCX files share no structure — naive every-N-characters cutting tore sentences in half and lost context. The docstring promised LangChain, but I wrote the splitting by hand, in two format-aware variants: PDF breaks on double newlines, sentence ends and single newlines while tracking the page number for attribution; DOCX has a boundary-priority system — a heading beats a paragraph, a paragraph beats a sentence — and keeps each chunk tied to its section. A 200-character overlap means a fact straddling two chunks can't die. The result: every answer can point to the page and section it came from.
[+][CH-03]
SentenceTransformer is synchronous and CPU-bound — called directly in an async handler it would block the MCP server's event loop and Claude Desktop would see frozen tools. I took the model off the loop: loading and encode go through a thread pool (run_in_executor), documents index in batches via asyncio.gather with return_exceptions, and an embedding failure degrades to a zero vector instead of killing indexing. I also caught a hidden landmine in a dead module: a logger writing to stdout, which in a STDIO server would break JSON-RPC framing — logs belong on stderr, and the unwired code went to the bin (−565 lines), because dead code is debt, not value.
[FIG. 4] AI LAYER
The language model lives on the client side — Claude Desktop via MCP, or Ollama in the free build — while my code owns retrieval: MiniLM embeddings, cosine ranking and source attribution. That split is deliberate: swapping the model never touches the pipeline, and the data never leaves the machine.
[FIG. 2A] CHUNKING ANATOMY
→ input: a plain PDF/DOCX — zero user-side preprocessing
[FIG. 5] GALLERY

