DEV Community

Build a Codebase Intelligence Tool Like repowise With a RAG-Assisted MCP for Your Monorepo

Originally published on tamiz.pro. Introduction Modern monorepos contain hundreds of thousands of files spanning multiple services, libraries, and configurations. Traditional code search-whether ripgrep, Sourcegraph, or IDE search-struggles with semantic queries like "how do we handle payment retries?" or "find all places where user permissions are checked". A RAG-assisted Model Context Protocol (MCP) server can turn your local codebase into a queryable knowledge base, giving LLMs and CLI tools accurate, context-rich answers. This tutorial shows you how to build a production-grade version of tools like repowise for your own monorepo. Table of Contents - 1. Architecture Overview - 2. Prerequisites - 3. Project Setup - 4. Indexing Pipeline - 5. MCP Server Implementation - 6. Client Integration - 7. Production Hardening - 8. Frequently Asked Questions 1. Architecture Overview We'll build three components: β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ File Watcher│────▢│ Indexer │────▢│ Vector Storeβ”‚ β”‚ (chokidar) β”‚ β”‚ (LangChain) β”‚ β”‚ (Qdrant) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ IDE / CLI │◀────│ MCP Server │◀────│ Retriever β”‚ β”‚ (Claude, β”‚ β”‚ (FastMCP) β”‚ β”‚ (Hybrid) β”‚ β”‚ Cursor) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ Indexer: Splits code into AST-aware chunks, embeds them, and stores them in a local vector database. MCP Server: Exposes a standardized interface (tools, resources, prompts) that any MCP-compatible client can consume. Retriever: Combines vector similarity with BM25 lexical search and AST context for precise retrieval. 2. Prerequisites - Node.js 20+ and pnpm/npm - Python 3.11+ (for embedding server) - A running Qdrant instance (local or remote) - A monorepo with a manageable file count ( ; } export interface IndexingConfig { monorepoRoot: string; excludePatterns: string[]; languages: string[]; } 4. Indexing Pipeline 4.1 AST-Aware Chunking We use tree-sitter for language-agnostic parsing. This preserves semantic boundaries (functions, classes) instead of arbitrary character splits. // packages/indexer/src/chunker.ts import { Parser } from "tree-sitter"; import * as ts from "tree-sitter-typescript"; const parser = new Parser(); parser.setLanguage(ts.language); export function chunkFile(content: string, filePath: string): CodeChunk[] { const tree = parser.parse(content); const chunks: CodeChunk[] = []; function traverse(node: any) { if (["function_declaration", "class_declaration", "method_definition"].includes(node.type)) { chunks.push({ id: ${filePath}:${node.startPosition.row}-${node.endPosition.row}, path: filePath, language: "typescript", startLine: node.startPosition.row, endLine: node.endPosition.row, content: content.slice(node.startPosition.byte, node.endPosition.byte), astMetadata: { type: node.type, name: node.children[0]?.text ?? "anonymous" } }); } for (const child of node.children) traverse(child); } traverse(tree.rootNode); return chunks; } 4.2 Embedding Generation Run a local embedding server using sentence-transformers for privacy and zero API cost. # packages/embedding-server/main.py from fastapi import FastAPI from pydantic import BaseModel from sentence_transformers import SentenceTransformer import uvicorn app = FastAPI() model = SentenceTransformer("BAAI/bge-small-en-v1.5") class EmbedRequest(BaseModel): texts: list[str] @app.post("/embed") def embed(req: EmbedRequest): embeddings = model.encode(req.texts, normalize_embeddings=True).tolist() return {"embeddings": embeddings} if name == "main": uvicorn.run(app, host="0.0.0.0", port=8000) 4.3 Indexing Orchestrator Watch for file changes and update the vector store incrementally. // packages/indexer/src/indexer.ts import chokidar from "chokidar"; import { QdrantClient } from "@qdrant/js-client-rest"; import { chunkFile } from "./chunker"; import axios from "axios"; const qdrant = new QdrantClient({ url: "http://localhost:6333" }); async function embed(texts: string[]) { const { data } = await axios.post("http://localhost:8000/embed", { texts }); return data.embeddings; } async function indexFile(filePath: string) { const content = await fs.readFile(filePath, "utf-8"); const chunks = chunkFile(content, filePath); if (chunks.length === 0) return; const embeddings = await embed(chunks.map(c => c.content)); const points = chunks.map((chunk, i) => ({ id: chunk.id, vector: embeddings[i], payload: chunk })); await qdrant.upsert("codebase", { points }); } async function watch(root: string) { const watcher = chokidar.watch(root, { ignored: /node_modules|.git|dist/g, persistent: true }); watcher.on("add", path => indexFile(path)); watcher.on("change", path => indexFile(path)); watcher.on("unlink", path => qdrant.delete("codebase", { wait: true, points: [path] })); } 5. MCP Server Implementation We'll use FastMCP (TypeScript SDK) to expose three tools: search_code , get_file_context , and explain_symbol . // packages/mcp-server/src/server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { QdrantClient } from "@qdrant/js-client-rest"; import axios from "axios"; const server = new McpServer({ name: "repo-wise", version: "1.0.0" }); const qdrant = new QdrantClient({ url: "http://localhost:6333" }); server.tool("search_code", { description: "Semantic + lexical search over the indexed monorepo", inputSchema: { type: "object", properties: { query: { type: "string", description: "Natural language query" }, limit: { type: "number", default: 10 } }, required: ["query"] } }, async ({ query, limit = 10 }) => { // 1. Embed the query const { data: embedData } = await axios.post("http://localhost:8000/embed", { texts: [query] }); const queryVector = embedData.embeddings[0]; // 2. Vector search const vectorResults = await qdrant.search("codebase", { query: queryVector, limit: limit * 2, // overshoot for reranking with_payload: true }); // 3. BM25 rerank (simplified: use Qdrant's built-in sparse vector if configured, // or implement a local BM25 scorer on the retrieved candidates) const scored = vectorResults.map(r => ({ path: r.payload.path, lines: ${r.payload.startLine}-${r.payload.endLine}, snippet: r.payload.content.slice(0, 200), score: r.score })); return { content: [{ type: "text", text: JSON.stringify(scored, null, 2) }] }; }); server.tool("get_file_context", { description: "Get full file content with line numbers for a given path", inputSchema: { type: "object", properties: { path: { type: "string" }, startLine: { type: "number" }, endLine: { type: "number" } }, required: ["path"] } }, async ({ path, startLine, endLine }) => { const content = await fs.readFile(path, "utf-8"); const lines = content.split("\n"); const slice = lines.slice(startLine ?? 0, endLine ?? lines.length).join("\n"); return { content: [{ type: "text", text: slice }] }; }); server.tool("explain_symbol", { description: "Explain what a symbol (function/class) does based on code and comments", inputSchema: { type: "object", properties: { symbolName: { type: "string" }, language: { type: "string", default: "typescript" } }, required: ["symbolName"] } }, async ({ symbolName, language }) => { const { data: embedData } = await axios.post("http://localhost:8000/embed", { texts: [symbolName] }); const results = await qdrant.search("codebase", { query: embedData.embeddings[0], limit: 5, filter: { must: [{ key: "language", match: { value: language } }] } }); const context = results.map(r => r.payload.content).join("\n\n---\n\n"); const prompt = You are a senior engineer. Explain the symbol "${symbolName}" based on the following code snippets:\n\n${context}; // In production, call an LLM here. For demo, return context. return { content: [{ type: "text", text: prompt }] }; }); const transport = new StdioServerTransport(); server.connect(transport); 6. Client Integration 6.1 Claude Desktop Add to your claude_desktop_config.json : { "mcpServers": { "repo-wise": { "command": "node", "args": ["./packages/mcp-server/dist/server.js"] } } } 6.2 Cursor IDE In Cursor settings, add the MCP server as a custom tool: { "tools": [ { "name": "repo-wise", "command": "node", "args": ["./packages/mcp-server/dist/server.js"] } ] } 7. Production Hardening 7.1 Incremental Indexing with Git Hooks Instead of filesystem watching (which misses renames, churn), use post-commit and post-merge hooks: #!/bin/bash # .git/hooks/post-commit ROOT=$(git rev-parse --show-toplevel) pnpm --filter @repo/indexer run index --root "$ROOT" --commit $(git rev-parse HEAD) 7.2 Hybrid Search with Qdrant Enable Qdrant's built-in sparse vectors (BM25) for better lexical recall: await qdrant.createCollection("codebase", { vectors: { size: 384, distance: "Cosine" }, sparse_vectors: { bm25: {} } }); Then during search, use Qdrant's search with using: ["bm25"] and fuse scores. 7.3 Security & Privacy - Run all components locally; never ship code to external APIs unless encrypted. - Use filesystem permissions to restrict access. - Sanitize file paths to prevent path traversal. - Add rate limiting to the embedding server. 8. Frequently Asked Questions Q: How large a monorepo can this handle? A: With local embeddings and Qdrant, we've tested repos up to 2M files (~50GB). The bottleneck is initial indexing time, not query latency. Q: Can I use this with non-TypeScript languages? A: Yes. tree-sitter supports 100+ languages. Update the languages array and use the corresponding tree-sitter- grammar. Q: How does this compare to GitHub Copilot Workspace? A: Copilot is cloud-hosted and proprietary. This tool runs entirely in your infrastructure, supports custom retrieval logic, and integrates with any MCP client (Claude, Cursor, custom IDEs). Ready to ship? Start with the indexer package and iterate on chunking strategies. For production deployments, consider adding a lightweight job queue (BullMQ) for indexing and a reverse proxy for the embedding serv

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.