Als kleines Nebenprojekt wollten wir unsere offizielle Dokumentation — die auch hier gehostet wird — als MCP-Server bereitstellen. Die Idee war zu testen, ob das die Entwicklung unserer eigenen Plattform erleichtern würde, da unsere KI-basierten IDEs weniger Schwierigkeiten hätten, architektonisches Wissen gegen einen leicht konsumierbaren öffentlichen Endpunkt zu prüfen.
Ausgehend von einem sehr einfachen Ansatz entwickelten wir folgenden Plan zur Umsetzung.
Als kurze Vorschau hier, was wir bauen wollten.
flowchart LR
X[Internet]
subgraph downtozero.cloud
A[downtozero.cloud]
end
subgraph W[MCP-Server]
N[Axum-Frontend]
T[Tantivy-Suche]
M[MCP-Server]
N -- /index.json --> A
N -- query --> T
N -- MCP request --> M
end
X -- Suche 'registry' --> N
X[Internet] -- GET /index.html --> A
Die aktuelle Website ist mit Hugo gebaut. Alle Inhalte dieser Seite werden also als Markdown erstellt und dann in HTML gerendert. Für Browser ist HTML ein gutes Format. Für Suchmaschinen sowie LLMs würde das jedoch viele Ressourcen verschwenden und nur einen geringen Einfluss auf die Ergebnisqualität haben. Besonders LLMs sind aber sehr gut darin, Markdown zu lesen und zu verstehen. Daher war schnell klar, dass wir dem LLM Markdown zuführen wollten. Gleichzeitig mussten wir diese Daten für unseren Suchserver konsumierbar machen.
Da Hugo mehrere Ausgabeformate und sogar beliebige Formate unterstützt, begannen wir, eine JSON-Ausgabe zu erstellen. Die Idee war, alle Seiten, die wir haben, in eine einzige große JSON-Datei zu rendern und zu sehen, was dabei herauskommt.
[
{
"contents": "We always aim ..",
"permalink": "https://downtozero.cloud/posts/2025/scale-to-zero-postgres/",
"title": "Scale-To-Zero postgresql databases"
},
{
"contents": "Eliminating Wasted Cycles in Deployment At DownToZero, ...",
"permalink": "https://downtozero.cloud/posts/2025/github-deployment/",
"title": "Seamless Deployments with the DTZ GitHub Action"
},
Um so etwas zu erzeugen, braucht Hugo eine Vorlage. Wir legten also die folgende Datei in das Standard-Templates-Verzeichnis. layouts/_default/index.json
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages }}
{{- /* start with an empty map */ -}}
{{- $page := dict -}}
{{- /* always present */ -}}
{{- $page = merge $page (dict
"title" .Title
"permalink" .Permalink) -}}
{{- /* add optional keys only when they have content */ -}}
{{- with .Params.tags }}
{{- if gt (len .) 0 }}
{{- $page = merge $page (dict "tags" .) -}}
{{- end }}
{{- end }}
{{- with .Params.categories }}
{{- if gt (len .) 0 }}
{{- $page = merge $page (dict "categories" .) -}}
{{- end }}
{{- end }}
{{- with .Plain }}
{{- $page = merge $page (dict "contents" .) -}}
{{- end }}
{{- $.Scratch.Add "index" $page -}}
{{- end }}
{{- $.Scratch.Get "index" | jsonify -}}
Um diese Vorlage rendern zu lassen, mussten wir die JSON-Ausgabe zur config.toml hinzufügen.
baseURL = 'https://downtozero.cloud/'
title = 'Down To Zero'
[outputs]
home = ["HTML", "RSS", "JSON"]
Jetzt, wo die JSON erzeugt ist, ist sie auf der Seite über /index.json verfügbar.
Das Abrufen der aktuellsten Inhalte wurde damit einfach, was die offene Frage nach der Implementierung der Suche aufwarf. Da wir unseren gesamten Stack auf serverlosen Containern aufbauen und ein hauptsächlich Rust-basiertes Backend haben, fiel unsere Wahl auch auf einen Rust-Container.
Also wählten wir https://github.com/quickwit-oss/tantivy. Die API für diese Engine ist unkompliziert und wir kümmern uns nicht zu sehr um Randfälle und Gewichtungen.
Hier ist ein kurzer Codeausschnitt, der das Abrufen und Indizieren zeigt.
fn test() {
let data = fetch_data().await.unwrap();
let index = build_search_index(data);
let results = search_documentation(index, "container registry".to_string());
}
async fn fetch_data() -> Result<Vec<DocumentationEntry>, reqwest::Error> {
let response = reqwest::get("https://downtozero.cloud/index.json")
.await
.unwrap();
let text = response.text().await.unwrap();
log::debug!("text: {text}");
let data = serde_json::from_str(&text).unwrap();
Ok(data)
}
fn build_search_index(data: Vec<DocumentationEntry>) -> Index {
let schema = get_schema();
let index = Index::create_in_ram(schema.clone());
let mut index_writer: IndexWriter = index.writer(50_000_000).unwrap();
for entry in data {
let doc = doc!(
schema.get_field("title").unwrap() => entry.title,
schema.get_field("contents").unwrap() => entry.contents.unwrap_or_default(),
schema.get_field("permalink").unwrap() => entry.permalink,
schema.get_field("categories").unwrap() => entry.categories.join(" "),
schema.get_field("tags").unwrap() => entry.tags.join(" "),
);
index_writer.add_document(doc).unwrap();
}
index_writer.commit().unwrap();
index
}
fn search_documentation(index: Index, query: String) -> Vec<(f32, DocumentationEntry)> {
let reader = index
.reader_builder()
.reload_policy(ReloadPolicy::OnCommitWithDelay)
.try_into()
.unwrap();
let searcher = reader.searcher();
let schema = get_schema();
let query_parser = QueryParser::for_index(
&index,
vec![
schema.get_field("title").unwrap(),
schema.get_field("contents").unwrap(),
schema.get_field("permalink").unwrap(),
schema.get_field("categories").unwrap(),
schema.get_field("tags").unwrap(),
],
);
let query = query_parser.parse_query(&query).unwrap();
let top_docs = searcher.search(&query, &TopDocs::with_limit(10)).unwrap();
let mut results = Vec::new();
for (score, doc_address) in top_docs {
let retrieved_doc: TantivyDocument = searcher.doc(doc_address).unwrap();
let entry = DocumentationEntry {
title: retrieved_doc
.get_first(schema.get_field("title").unwrap())
.unwrap()
.as_str()
.unwrap()
.to_string(),
contents: Some(
retrieved_doc
.get_first(schema.get_field("contents").unwrap())
.unwrap()
.as_str()
.unwrap()
.to_string(),
),
permalink: retrieved_doc
.get_first(schema.get_field("permalink").unwrap())
.unwrap()
.as_str()
.unwrap()
.to_string(),
categories: retrieved_doc
.get_first(schema.get_field("categories").unwrap())
.unwrap()
.as_str()
.unwrap()
.split(" ")
.map(|s| s.to_string())
.collect(),
tags: retrieved_doc
.get_first(schema.get_field("tags").unwrap())
.unwrap()
.as_str()
.unwrap()
.split(" ")
.map(|s| s.to_string())
.collect(),
};
results.push((score, entry));
}
results
}
Now that we have the content covered, let’s continue with the more interesting part: the MCP server.
Da alle unsere Services in Rust geschrieben sind, setzten wir uns das Ziel, diesen Service ebenfalls in Rust zu implementieren. Glücklicherweise hat das MCP-Projekt eine Rust-Referenzimplementierung für Clients und Server.
Wir sind dem Beispiel im Wesentlichen Wort für Wort gefolgt und hatten sehr schnell einen lokal laufenden MCP-Server.
Hier ist das komplette GitHub-Repo für alle, die in alle Details einsteigen wollen.
https://github.com/DownToZero-Cloud/dtz-docs-mcp
Als wir den MCP-Server deployen wollten, erhielten wir schnell von den LLM-Clients die Fehlermeldung, dass entfernte MCP-Server nur über TLS unterstützt werden. Das machte unser Experiment nicht leichter.
Wir setzten schnell auf Let’s Encrypt, um beim Start ein TLS-Zertifikat zu erzeugen und es zum Hosten unseres MCP zu verwenden. Da wir bereits Code für andere Teile der DTZ-Plattform haben, waren nur wenige Anpassungen nötig.
Wir werden einen zusätzlichen Beitrag veröffentlichen, der detailliert beschreibt, wie man Let's Encrypt in einer axum-Server-Umgebung zum Laufen bringt.
Let’s Encrypt support for our MCP Server
Zusammenfassend: Wir haben unseren MCP-Server zum Laufen gebracht. Er ist im Internet verfügbar, und wir haben ihn in unsere Cursor-, Gemini-CLI- und ChatGPT-Clients eingebunden. Interessanterweise reagiert jeder Client sehr unterschiedlich darauf. Cursor ignoriert die Informationsquelle weitgehend und fragt unabhängig von der Aufgabe nie nach zusätzlichen Informationen. Gemini nutzt das MCP, wenn es erforderlich ist. Es ist nicht klar, wie oder wann es aufgerufen wird, aber es verwendet die verfügbare Informationsquelle. ChatGPT nutzt das MCP nicht und greift stattdessen immer auf seine eigene Web-Suchfunktion zurück, die gegenüber dem MCP-Server Vorrang zu haben scheint. Im Research-Modus verwendet ChatGPT das MCP, aber die Ergebnisse scheinen nicht wertvoller zu sein als die normale Web-Suche.