# The third MCP server was reasonable. The rest were not

> MCP was the right answer for giving an agent a project's documentation. As the count grew, the servers themselves became the thing needing maintenance.

- Published: 2026-09-19
- Category: Tools & Technologies
- Tags: Docker, PostgreSQL, TypeScript
- Reading time: 6 min read
- Source: https://www.muhammetsafak.com.tr/en/blog/one-mcp-server-per-project-stops-scaling/
- Language: en-US
- Author: Muhammet Şafak

---
Standing up a separate MCP server for every project looked reasonable for the
first three. Then the servers started living apart: which one indexed which
directory, which had last synced, which carried its own chunking code.

This is the story of how four requirements turned into four design decisions
while I was opening the documentation of dozens of projects to my agents. The
short answer: the right unit for giving an agent context is not one **server**
per project but one **endpoint** per project. What built those four is
[Contextator](/en/portfolio/contextator/).

## The problem was not the token bill

My first diagnosis was wrong. I read the agent rediscovering the project every
session as a line item: it walks the files, reads them, derives the same
structure over and over, and that costs money.

The bill is real, but it is not the point. Where discovery ends, the model does
not stop. When it cannot find what it is looking for, it keeps producing an
answer and fills the gap with an assumption. That happens quietly: there is no
line in the output saying "I could not find this file", there is a plausible
answer with nothing under it. You see the token bill at the end of the month;
you see the assumption only if somebody reads that code.

The right answer is giving the model the place it should be searching — which is
MCP. The protocol is not the hard part.

## The hard part is doing the same thing dozens of times

Writing an MCP server for one project is a few hours the first time. The second
is copy and paste. The third is still reasonable.

By the fifth you notice what it actually is: every new project means writing the
same chunker, the same embedding loop and the same incremental indexer once
more — and maintaining all of them separately afterwards. One supports a new
file type, another does not. One drops deleted files from its index, another
leaves them. Seeing which one synced when means looking at each in turn.

There is a second problem on top: a project's documentation does not live in one
place. Part of it is in the repository, part in the handbook in Notion, part in
an Obsidian vault, part in an archive on a shared drive. A tool that indexes one
folder waits for a human to consolidate first — which never happens.

Contextator collapses that repetition into one installation. A project is the
sum of its sources: a mounted folder, a git repository (or a single subdirectory
of it), an uploaded archive, an Obsidian vault, a Notion workspace. They meet at
one endpoint, and every source is mounted under its own name, so a document
reads as `handbook/install.md`.

## First decision: isolation is not a setting, it is the address

Each project gets its own URL: `/mcp/<project>`. A client connected to
`/mcp/billing` never sees `/mcp/mobile`.

The alternative was one endpoint with a `project` argument. In that design
isolation would depend on the agent passing the right one — and confidentiality
that rests on a model's choice of argument is not confidentiality.

The cost is plain and accepted up front: **there is no cross-project search.**
You cannot ask "which project did I use this pattern in?" in a single question.
That is not an omission but the decision itself: if you could, every search
would silently span every project.

## Second decision: not needing a key in order to work

Embeddings are generated on the machine's own CPU by default. The reason comes
before cost: when a tool built to be queried constantly asks for an API key **in
order to work at all**, it breaks the "runs on your own server" promise at the
outset. A few environment variables switch it to OpenAI embeddings, but the
default path never leaves the host.

The cost is paid at first start: the model is downloaded and the container takes
a few minutes to come up. Later starts take seconds. To keep installation to one
command, PostgreSQL and the application sit in the same image — a deliberate
departure from one-container-one-process orthodoxy, and that is its price.

## Third decision: a search that asks two indexes

A sentence model has no representation of `HALYARD_DISPATCH_TIMEOUT`. It has a
representation of the words around it. And a good share of what people actually
search documentation for is exactly that: a constant name, an error code, a flag.

So every chunk sits in a full-text index as well as a vector one, and a search
takes both lists and fuses their rankings. Ranks rather than scores — because
any weighting of scores would have to be re-learnt every time the embedding
model changed, while ranks survive a model swap untouched.

The visible cost: the similarity score on a result no longer explains why that
result is where it is.

## Fourth decision: saying when nothing was found

When nothing clears the relevance floor, the search does not return its least
bad hit; it says there is no good match and points to the tool that lists the
documents instead.

The same discipline runs through indexing: a scanned PDF with no text layer is
refused rather than indexed. Because the failure nobody notices is the one that
*produces* a document: the empty string gets indexed, and the project now holds
a document that exists, is listed, matches nothing, and reads as blank when
opened.

## A limit that was measured and then left alone

To be honest about it, one thing does not work: cross-lingual search. A question
asked in Turkish whose answer lives on an English page mostly goes unanswered.

That is not a to-do but a closed decision. Two remedies were tried. Hybrid
search first: it recovered identifiers and not one natural-language question.
Then a multilingual reranking model: it raised cross-lingual recall but took a
search from milliseconds to seconds and cost a number of ordinary questions
their rank-one answer. Both were measured, and the measurements are in the
repository.

The real fix would be a second translation-trained encoder: four times the
download, a second vector column, and a full re-index on every installation. For
a documentation server that price was not paid. So the finding sits in the
documentation rather than on a roadmap — and if your corpus is genuinely
bilingual and that kind of search is essential, it is a reason to pick a
different tool.

## What this costs in total

What I gained: opening a new project's documentation to an agent is no longer
writing a server, it is adding a source in a dashboard. When sources change,
only the changed file is re-embedded and a deleted file's documents are dropped.

What I pay is just as plain: no cross-project search, a first start that waits
on a model download, one container that is not one process, and no searching
across two languages. All four came out of a decision, and none of them is
reversible — knowing what a tool does not do is as much part of the installation
decision as knowing what it does.

The route I took and the reasoning behind the decisions are in the
[Labs entry](/en/labs/contextator-lab/); the product itself is in the
[portfolio](/en/portfolio/contextator/). The tool is open source and runs on
your own server.
