Building a MCP Server for Service Documentation

created: Freitag, Okt. 10, 2025

Als ein spaßiges Projekt wollten wir unsere offizielle Dokumentation — die auch hier gehostet wird — als MCP-Server zugänglich machen. Die Idee war zu testen, ob dies die Entwicklung unserer eigenen Plattform erleichtert, da unsere KI-basierten IDEs weniger Schwierigkeiten hätten, architektonisches Wissen gegen einen einfach konsumierbaren öffentlichen Endpunkt zu prüfen.

Nun, ganz naiv angefangen, kamen wir auf den folgenden Plan zur Umsetzung.

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

Steps

  1. Exportiere die Webseite in ein leichter zu konsumierendes Format
  2. Konsumiere und durchsuche die relevanten Inhalte
  3. Baue einen MCP-Server mit Suchfunktionalität

Step 1 - Export the website

Die aktuelle Webseite wurde mit Hugo erstellt. Alle Inhalte auf dieser Seite werden als Markdown erzeugt und anschließend in HTML gerendert. Für Browser ist HTML ein gutes Format. Für Suchmaschinen und LLMs würde das jedoch viele Ressourcen verschwenden und nur minimal die Ergebnisqualität verbessern. Aber insbesondere LLMs sind sehr gut darin, Markdown zu lesen und zu verstehen. Deshalb war klar, dass wir Markdown in das LLM einspeisen wollten. Gleichzeitig benötigten wir diese Daten für unseren Suchserver.

Da Hugo mehrere Ausgabeformate und sogar beliebige Formate unterstützt, begannen wir, eine JSON-Ausgabe zu erstellen. Die Idee war, alle vorhandenen Seiten 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, benötigt Hugo eine Vorlage. Deshalb legten wir 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 zu rendern, mussten wir die JSON-Ausgabe in der config.toml hinzufügen.

baseURL = 'https://downtozero.cloud/'
title = 'Down To Zero'

[outputs]
  home = ["HTML", "RSS", "JSON"]

Step 2 - Consume and search

Da die JSON-Datei nun erstellt ist, ist sie auf der Seite über /index.json verfügbar.

Das Abrufen des aktuellsten Inhalts wurde dadurch einfach, was zur offenen Frage der Implementierung der Suche führte. Da wir unseren gesamten Stack auf serverlosen Containern mit einem hauptsächlich in Rust geschriebenen Backend aufbauen, fiel unsere Wahl auch auf einen Rust-Container.

Wir wählten also https://github.com/quickwit-oss/tantivy.
Die API für diese Engine ist unkompliziert und wir kümmern uns nicht allzu sehr um Randfälle und Gewichtungen.

Hier ein kurzer Codeausschnitt zum Abrufen und Indexieren.

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 wir jetzt mit dem Inhalt versorgt sind, kommen wir zum interessanteren Teil: dem MCP-Server.

Step 3 - Build the MCP server

Da all unsere Dienste in Rust geschrieben sind, setzten wir uns das Ziel, diesen Service ebenfalls in Rust zu bauen. Glücklicherweise hat das MCP-Projekt eine Rust-Referenzimplementation für Clients und Server.

Wir folgten im Wesentlichen dem Beispiel Punkt für Punkt und bekamen den MCP-Server recht schnell lokal zum Laufen.

Hier ist das komplette GitHub-Repository für alle, die sich in die Details vertiefen möchten.

https://github.com/DownToZero-Cloud/dtz-docs-mcp

Step 3.5 - unexpected complication

Nun wollten wir diesen MCP-Server deployen und bekamen schnell die Fehlermeldung von den LLM-Clients, dass entfernte MCP-Server nur über TLS unterstützt werden. Das machte unser Experiment nicht gerade einfacher.

Wir übernahmen schnell Let’s Encrypt, um beim Start ein TLS-Zertifikat zu erzeugen und es zum Hosten unseres MCP-Servers zu verwenden. Da wir bereits Code für andere Teile der DTZ-Plattform hatten, waren nur wenige Anpassungen nötig.

Wir werden einen extra Beitrag mit einer detaillierten Anleitung veröffentlichen, wie man Let's Encrypt in einer Axum-Server-Umgebung zum Laufen bringt.

Let’s Encrypt support for our MCP Server

Final thoughts

Abschließend lässt sich sagen, dass wir unseren MCP-Server zum Laufen gebracht haben. Er ist im Internet verfügbar und wir haben ihn in unsere Cursor-, Gemini-CLI- und ChatGPT-Clients eingebunden. Interessanterweise reagieren die Clients sehr unterschiedlich darauf. Cursor ignoriert die Informationsquelle komplett und fragt nie nach zusätzlichen Informationen, unabhängig von der Aufgabe. Gemini benutzt das MCP, wenn es benötigt wird. Es ist nicht klar, wie oder wann genau es aufgerufen wird, aber die verfügbaren Informationen werden genutzt. ChatGPT verwendet das MCP nicht und greift immer auf seine eigene Web-Suche zurück, die Vorrang vor dem MCP-Server hat. Im Research-Modus verwendet ChatGPT den MCP, aber die Ergebnisse scheinen nicht wertvoller zu sein als die normale Web-Suche.

Github Repo