Als Spaßprojekt 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 Probleme hätten, architektonisches Wissen gegen einen leicht zu konsumierenden öffentlichen Endpunkt zu prüfen.
Ausgehend von einer sehr naiven Annahme haben wir folgenden Plan zur Umsetzung entwickelt.
Als kurze Vorschau hier, was wir bauen wollen.
flowchart LR
X[Internet]
subgraph downtozero.cloud
A[downtozero.cloud]
end
subgraph W[MCP server]
N[Axum frontend]
T[tantivy search]
M[MCP Server]
N -- /index.json --> A
N -- query --> T
N -- MCP request --> M
end
X -- search 'registry' --> N
X[Internet] -- GET /index.html --> A
Die aktuelle Website ist mit Hugo gebaut. Alle Inhalte auf 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 minimalen Einfluss auf die Qualität des Ergebnisses haben. Besonders LLMs sind aber sehr gut darin, Markdown zu lesen und zu verstehen. Daher war schnell klar, dass wir Markdown in das LLM einspeisen wollten. Gleichzeitig mussten wir diese Daten für unseren Suchserver konsumierbar machen.
Da Hugo mehrere Ausgabeformate und sogar beliebige Formate unterstützt, begannen wir damit, eine JSON-Ausgabe zu bauen. Die Idee war, alle Seiten, die wir haben, in ein einzelnes großes JSON 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, benötigt Hugo eine Vorlage. Wir haben also die folgende Datei in das Standard-Templates-Verzeichnis gelegt. 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 in der config.toml hinzufügen.
baseURL = 'https://downtozero.cloud/'
title = 'Down To Zero'
[outputs]
home = ["HTML", "RSS", "JSON"]
Sobald das JSON erzeugt ist, ist es auf der Seite über /index.json verfügbar.
Das Abrufen der aktuellsten Inhalte wurde so einfach, wodurch sich die offene Frage der Implementierung einer Suche stellte. Da wir unseren gesamten Stack in serverlosen Containern mit einem überwiegend Rust-basierten Backend aufbauen, fiel auch die Wahl hier auf einen Rust-Container.
Wir entschieden uns daher für https://github.com/quickwit-oss/tantivy. Die API dieser Engine ist unkompliziert und wir legen nicht zu viel Wert auf Randfälle und Gewichtungen.
Hier ein kurzer Codeausschnitt, der Abruf und Indexierung 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
}
Da der Inhalt nun abgedeckt ist, fahren wir mit dem interessanteren Teil fort: dem MCP-Server.
Da alle unsere Dienste in Rust gebaut sind, war es unser Ziel, diesen Dienst ebenfalls in Rust zu entwickeln. Glücklicherweise stellt das MCP-Projekt eine Rust-Referenzimplementierung für Clients und Server zur Verfügung.
Wir haben dem Beispiel im Wesentlichen Wort für Wort gefolgt und schnell einen MCP-Server lokal zum Laufen gebracht.
Hier das komplette GitHub-Repo für alle, die in die Details einsteigen möchten.
https://github.com/DownToZero-Cloud/dtz-docs-mcp
Als wir den MCP-Server nun bereitstellen 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 einfacher.
Wir setzten schnell Let’s Encrypt ein, 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 nicht viele Anpassungen nötig.
Wir werden einen zusätzlichen Beitrag mit einer detaillierten Beschreibung veröffentlichen, wie man Let's Encrypt in einer axum-Server-Konfiguration betreibt.
Let’s Encrypt support for our MCP Server
Zusammenfassend haben wir unseren MCP-Server zum Laufen gebracht. Er ist im Internet erreichbar, und wir haben ihn in unsere Cursor-, Gemini-CLI- und ChatGPT-Clients integriert. Interessanterweise reagiert jeder Client sehr unterschiedlich darauf. Cursor ignoriert die Informationsquelle schlichtweg und fragt niemals nach zusätzlichen Informationen, unabhängig von der Aufgabe. Gemini nutzt das MCP bei Bedarf. 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 Websuche zurück, die Vorrang vor dem MCP-Server hat. Im Research-Modus verwendet ChatGPT das MCP, aber die Ergebnisse scheinen nicht wertvoller zu sein als die Websuche.