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 bereitstellen. Die Idee war zu testen, ob das die Entwicklung unserer eigenen Plattform erleichtert, da unsere KI-basierten IDEs dann weniger Schwierigkeiten hätten, architektonisches Wissen über einen einfach nutzbaren öffentlichen Endpunkt abzufragen.

Ausgehend von einem sehr naiven Ansatz kamen wir auf den folgenden Plan, um dies umzusetzen.

Als kleiner Vorgeschmack 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 Website in ein leichter nutzbares Format
  2. Verarbeite und suche nach relevanten Inhalten
  3. Baue einen MCP-Server mit Suchfunktionalität

Step 1 - Export the website

Die aktuelle Website ist mit Hugo gebaut. Alle Inhalte dieser Seite werden also als Markdown erstellt und danach in HTML gerendert. Für Browser ist HTML ein gutes Format. Für Suchmaschinen sowie LLMs wäre das allerdings sehr ressourcenintensiv und würde nur minimal bessere Resultate liefern. Besonders LLMs sind jedoch sehr gut im Lesen und Verstehen von Markdown. Somit wurde klar, dass wir Markdown in das LLM einspeisen wollten. Gleichzeitig mussten wir diese Daten aber auch für unseren Suchserver nutzbar machen.

Da Hugo mehrere Ausgabeformate und auch beliebige Formate unterstützt, begannen wir damit, eine JSON-Ausgabe zu erstellen. Die Idee war, alle vorhandenen Seiten in ein großes JSON zu rendern und zu schauen, 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. Diese haben wir im Verzeichnis der Standard-Templates abgelegt.
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 -}}

Damit Hugo das Template rendert, mussten wir die JSON-Ausgabe noch in der config.toml ergänzen.

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

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

Step 2 - Consume and search

Da das JSON nun gebaut wird, ist es über die Seite unter /index.json verfügbar.

Das Abrufen der neuesten Inhalte wurde dadurch einfach, was die offene Frage nach einer Such-Implementierung aufwarf. Wir bauen unseren gesamten Stack auf serverlosen Containern mit einem hauptsächlich in Rust geschriebenen Backend. Daher fiel unsere Wahl auch hierfür auf einen Rust-Container.

Wir entschieden uns für https://github.com/quickwit-oss/tantivy.
Die API dieser Engine ist einfach und wir kümmern uns nicht zu sehr um Randfälle oder Gewichtungen.

Hier ein kurzer Codeausschnitt für Abruf und Indizierung.

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 den Content nun abgedeckt haben, machen wir weiter mit dem interessanteren Teil: dem MCP-Server.

Step 3 - Build the MCP server

Da alle unsere Services in Rust entwickelt sind, setzten wir uns das Ziel, diesen Service ebenfalls in Rust zu bauen. Glücklicherweise verfügt das MCP-Projekt über eine Rust-Referenzimplementierung für Clients und Server.

Wir folgten dem Beispiel im Wesentlichen Wort für Wort und bekamen schnell einen lokal laufenden MCP-Server.

Hier ist das komplette GitHub-Repo für alle, die tiefer in die Details gehen wollen.

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

Step 3.5 - unexpected complication

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

Wir setzten schnell auf Let’s Encrypt, um beim Start ein TLS-Zertifikat zu generieren und damit unseren MCP zu hosten. Da wir bereits Code für andere Teile der DTZ-Plattform hatten, brauchten wir nicht viele Anpassungen.

Wir werden einen separaten Beitrag veröffentlichen, der detailliert beschreibt, wie man Let's Encrypt in einer axum-Server-Umgebung betreibt.

Let’s Encrypt support for our MCP Server

Final thoughts

Zusammenfassend haben wir unseren MCP-Server ans Laufen gebracht. Er ist im Internet verfügbar und wir haben ihn in unsere Cursor-, Gemini-CLI- und ChatGPT-Clients integriert. Interessanterweise reagiert jeder Client sehr unterschiedlich darauf. Cursor ignoriert die Datenquelle vollständig und fragt nie nach zusätzlichen Informationen, egal um welche Aufgabe es geht. Gemini nutzt MCP bei Bedarf. Wann und wie genau ist unklar, aber die verfügbare Datenquelle wird verwendet. ChatGPT nutzt MCP nicht und greift immer zuerst auf die eigene Websuchfunktion zurück, die Vorrang vor dem MCP-Server hat. Im Research Mode nutzt ChatGPT MCP, doch die Ergebnisse scheinen nicht wertvoller als die Websuche.

Github Repo