Skip to content

← Side Quests

Codebase MCP

An AI assistant can only reason about what it has been handed, and pasting files into a chat window is a poor way to hand it a codebase. Codebase MCP is a context server I designed and built for my own work: it indexes a body of work — source code, the documents around it, Notion pages, Git history — and exposes that index to AI clients over the Model Context Protocol, the standard those clients use to reach outside their own conversation. The server decides what context to supply and leaves the reasoning to the client. That division is why this is a protocol server rather than another chat application.

Context

The context that answers a real question about a project is rarely in one place. The implementation is in source files. The intent behind it is in a PRD or a markdown document. The decision that shaped it may only exist in Notion, and the reason a behavior changed is in a commit. A tool that can read code but not the documents describing it answers half the questions asked of it.

The corpus is private work, which set the operating constraint. Sending a repository to a hosted embedding provider means shipping the whole thing somewhere in order to search it, so embeddings and storage both had to be able to run locally, on the machine that already holds the code.

That leaves the harder half of the problem. The material being indexed has almost nothing in common structurally — a TypeScript module, a slide deck, a Notion database, a commit diff — and yet a single query string has to be resolved against all of it. The interface is also driven by someone else's client, so the tool surface has to be legible to a model choosing between options, not just to a human reading documentation.

Architecture

The server owns indexing, storage, and retrieval. A client connects, registers a filesystem repository or a Notion hub as a project, selects the active project for that session, and then calls tools against it. Everything indexed lands in PostgreSQL with pgvector, in one project, file, chunk, embedding, and Git schema shared by every source type.

The client asks; the server decides what to send. Semantic retrieval returns retrieved context as structured data for the client's model to reason over — the server never produces the final answer. Keeping that line sharp is what makes the server reusable across any compatible client, and it keeps the claim honest: this system is accountable for what context it supplies, not for what a model concludes from it.

Precision and discovery are different tools. Reading an exact file, listing a project tree, searching for a string, and walking Git log, history, and diffs are deterministic operations, and they sit alongside semantic retrieval rather than underneath it. When the client already knows where to look, guessing is a regression. Presenting both as explicitly chosen tools lets the model ask for certainty when it has enough information to, and for similarity when it does not.

Local-first is an architecture decision as much as a privacy one. Embeddings are generated through a local Ollama model, PostgreSQL runs in Docker Compose alongside it, and indexing a repository moves no source code off the machine. Connecting a Notion workspace is the one path that crosses that boundary, and it is opt-in per project.

One transport detail shaped the server's foundations: the MCP SSE transport expects Node's HTTP request and response objects, which do not line up with the Fetch-style server boundary Bun encourages. The server is built against Node's HTTP types for that reason rather than by preference.

Stack

Server — Bun · TypeScript · MCP over HTTP/SSE · Zod

Retrieval and storage — Ollama embeddings · PostgreSQL 16 · pgvector

Sources — Filesystem and Git · PDF, DOCX, PPTX · Notion API

Local infrastructure — Docker Compose

Engineering challenges

Code, documents, Notion, and Git do not behave alike

The problem. One question might be answered by a function, a paragraph in a PRD, a row in a Notion database, or the commit that changed the behavior. Treating each of those as its own product would have meant building four retrieval systems and asking the client to know which one to consult.

Why it was hard. These sources differ in every dimension ingestion cares about. Structure: code has syntax, a slide deck has slides, a Notion page has blocks. Extraction cost: a PDF has to be parsed, a source file only read. Freshness: a file changes on disk and can be hashed, while Notion only reports timestamps through an API. Flattening all of that into fixed-size text windows would produce chunks that cut functions in half and lose the boundaries a reader relies on.

The decision. One project, chunk, and embedding model for every source, with the differences confined to ingestion rather than leaking into retrieval.

Implementation. Registration walks the source with ignore rules that exclude secrets, binaries, media, and generated output. TypeScript and JavaScript are chunked along AST structure where it is available, so a chunk is a unit someone would recognize rather than an arbitrary slice. Office documents are parsed once, at index time, and stored as chunks — which also means the file-reading tool serves stored ordinal windows instead of reparsing a binary document on every request. Notion pages and database entries normalize into the same chunks. Git metadata is stored alongside, so history is queryable through the same project.

Outcome. A single index and a single tool surface cover code, documents, Notion, and Git. Supporting a new kind of source is an ingestion problem rather than a new product, and the deliberate tradeoff is that documents and code then compete inside one vector space — which is the next problem.

Documentation that outranked the code implementing it

The problem. Because implementation and prose share one vector space, a document that restates a question in the question's own words can score above the code that actually implements the behavior. Ask how a feature works and get the PRD describing how it should work.

Why it was hard. This is not a tuning oversight; similarity is doing exactly what it is meant to. Prose written to describe behavior is lexically closer to a question about that behavior than the implementation is. Excluding documents outright breaks the many questions documents genuinely answer best. And there is nobody to ask for clarification — the client sends a string, and the server has to infer what kind of answer that string wants.

The decision. Stop treating every request as the same kind of request. Read intent from the query, and let an explicit file reference override retrieval entirely when the reference is unambiguous.

Implementation. High-confidence explicit file references scope retrieval to that file and return its stored chunks directly. An earlier, more aggressive resolver would latch onto general wording and stop semantic search on an ambiguous tie; the corrected policy treats low confidence as a reason to search rather than a reason to halt. For unscoped questions, the candidate pool is expanded, the query is classified as implementation-oriented or document-oriented, and results are reranked by file type and exported-name overlap, with an optional filter that prefers code.

Outcome. Retrieval has two modes and a defined route between them, rather than one ranking that quietly favors whichever text is most quotable. The failure case that remains is narrow and specific: a document that reproduces a query almost verbatim can still win, which is a known cost of one shared index.

An embedding model wider than the index could hold

The problem. The chosen local embedding default emits 2,560-dimensional vectors. pgvector's IVFFlat indexing supports up to 2,000 dimensions for the ordinary vector type. Stored naively, the embeddings work and the index does not.

Why it was hard. The two obvious paths are both concessions — downgrade to a narrower model, or drop approximate-nearest-neighbor indexing and scan exhaustively. Dimension is also not merely a column width. It determines the storage type and the operator class of the index, so changing the embedding model is a schema migration, and one where the existing rows are not just stale but meaningless, since vectors produced by different models are not comparable.

The decision. Make storage type a function of the embedding dimension, and treat a dimension change as an explicit destructive migration instead of something to work around.

Implementation. Dimensions inside the supported range use the ordinary vector type; wider ones use halfvec with the matching IVFFlat operator class. The configured dimension is validated against what the database already holds, indexing is skipped with a warning where it cannot be supported, and a schema that cannot safely reuse existing embeddings requires an explicit wipe and reindex — gated behind a confirmation, because it destroys data.

Outcome. Approximate-nearest-neighbor indexing survived the choice of a high-dimensional local model, and changing embedding configuration fails loudly rather than silently mixing incomparable vectors into one index.

Reindexing a repository that changes while you work in it

The problem. A repository is edited continuously, so an index built once is wrong within the hour. Rebuilding it on every change is the obvious fix and the wrong one — every file re-read, re-chunked, and re-embedded to capture one edit.

Why it was hard. Editing produces bursts, not events. A formatter pass or a branch switch rewrites many files in a moment, and a watcher that responds to each one starts overlapping indexing runs that compete over the same rows for the same project. Notion has no equivalent signal at all, so freshness there cannot use the same mechanism.

The decision. Do the smallest amount of work that makes the index correct, and never let two runs of it happen at once.

Implementation. Content hashes let unchanged files be skipped before the expensive parse and embed steps. Watcher events are debounced so a burst of writes collapses into a single run. An in-process promise map enforces single-flight indexing per project, so a second request joins the run already in progress rather than starting a competing one — the right scope for a single-process local server, where a horizontally scaled one would need a real distributed lock. Notion projects are refreshed through explicit reindexing instead.

Outcome. Keeping the index current is incremental in the common case, and the full rebuild is reserved for the cases that genuinely require it, such as an embedding schema change.

Outcome

The server runs, and it does the thing it was built to do: a compatible MCP client can be pointed at a project and then read files exactly, inspect a project tree, search text, query Git history, or ask a question and receive retrieved context across code, office documents, and Notion — with the corpus and the embedding model staying on the machine. Tool inputs are validated with Zod, MCP traffic is instrumented with structured logs, and the indexing, retrieval, summary, document, and Notion paths are exercised by smoke scripts.

What the project validated is the boundary it is built on. Separating context supply from generation, and deterministic inspection from semantic retrieval, turned out to be the decision everything else rests on. It kept the tool surface honest about what it can promise, made the server usable by any client that speaks the protocol rather than one application, and meant the hard problems could be attacked where they actually live — in chunking, in ranking policy, in storage and index compatibility — instead of being buried inside a prompt.

The capability that came out of it is the one I wanted: a project's code and the material written around it become one thing an assistant can consult, without either the question or the corpus leaving the machine.