Building a MCP Server for Service Documentation

created: vendredi, oct. 10, 2025

Pour le fun, nous avons voulu exposer notre documentation officielle — qui est également hébergée ici — en tant que serveur MCP. L’idée était de tester si cela faciliterait le développement de notre propre plateforme, puisque nos IDEs basés sur l’IA auraient moins de difficulté à vérifier les connaissances architecturales contre un point d’accès public facile à consommer.

En partant d’une approche très naïve, nous avons élaboré le plan suivant pour l’implémenter.

En bref, voici ce que nous voulons construire.

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

Étapes

  1. Exporter le site dans un format plus facile à consommer
  2. Consommer et rechercher le contenu pertinent
  3. Construire un serveur MCP avec une capacité de recherche

Étape 1 - Exporter le site

Le site actuel est construit avec Hugo. Ainsi tout le contenu de ce site est créé en Markdown puis rendu en HTML. Pour les navigateurs, le HTML est un bon format. Pour les moteurs de recherche ainsi que pour les LLM, cela gaspille pas mal de ressources et n’a qu’un impact minimal sur la qualité du résultat. Mais les LLM sont particulièrement bons pour lire et comprendre le Markdown. Il est donc devenu évident que nous voulions fournir du Markdown au LLM. En parallèle, nous devions consommer ces données pour notre serveur de recherche.

Comme Hugo supporte plusieurs formats de sortie et même des formats arbitraires, nous avons commencé par créer une sortie JSON. L’idée était de rendre toutes les pages que nous avons en un seul gros JSON et de voir ce que cela donne.

[
  {
    "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"
  },

Maintenant, pour créer quelque chose comme ça, Hugo doit disposer d’un template. Nous avons donc placé le fichier suivant dans le répertoire des templates par défaut. 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 -}}

Pour que ce template soit rendu, nous avons dû ajouter la sortie JSON au config.toml.

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

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

Étape 2 - Consommer et rechercher

Maintenant que nous avons construit le JSON, il devient disponible sur le site via /index.json.

Récupérer le contenu le plus à jour est devenu simple, ce qui a ouvert la question de l’implémentation de la recherche. Étant donné que nous construisons toute notre stack sur des conteneurs serverless, avec un backend principalement en Rust, notre choix s’est naturellement porté sur un conteneur Rust.

Nous avons donc choisi https://github.com/quickwit-oss/tantivy. L’API de ce moteur est simple et nous ne nous soucions pas trop des cas limites et des poids.

Voici un court extrait de code montrant la récupération et l’indexation.

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
}

Maintenant que nous avons couvert le contenu, poursuivons avec la partie la plus intéressante : le serveur MCP.

Étape 3 - Construire le serveur MCP

Puisque tous nos services sont construits en Rust, nous nous sommes fixés pour objectif de construire ce service en Rust. Heureusement, le projet MCP propose une implémentation de référence en Rust pour les clients et les serveurs.

Nous avons essentiellement suivi l’exemple à la lettre et avons rapidement fait tourner un serveur MCP en local.

Voici le dépôt GitHub complet pour tous ceux qui veulent entrer dans les détails.

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

Étape 3.5 - complication inattendue

Quand nous avons voulu déployer ce serveur MCP, nous avons rapidement reçu l’erreur des clients LLM indiquant que les serveurs MCP distants ne sont pris en charge que via TLS. Cela n’a pas vraiment facilité notre expérience.

Nous avons rapidement adopté Let’s Encrypt pour générer un certificat TLS au démarrage et l’utiliser pour héberger notre MCP. Comme nous disposions déjà de code pour d’autres parties de la plateforme DTZ, nous n’avons pas eu besoin de trop d’ajustements pour cela.

Nous ferons un autre post détaillant comment faire fonctionner Let's Encrypt dans une configuration de serveur axum.

Let’s Encrypt support for our MCP Server

Réflexions finales

En conclusion, nous avons réussi à faire fonctionner notre serveur MCP. Il est disponible sur Internet, et nous l’avons ajouté à nos clients Cursor, Gemini CLI et ChatGPT. Fait intéressant, chaque client réagit très différemment. Cursor ignore simplement la source d’information et ne sollicite jamais d’informations supplémentaires, quelle que soit la tâche. Gemini utilise le MCP si nécessaire. Il n’est pas clair comment ni quand il est invoqué, mais il utilise la source d’information disponible. ChatGPT n’utilise pas le MCP et revient toujours à sa propre fonction de recherche web, qui prend le pas sur le serveur MCP. En mode Recherche, ChatGPT utilise le MCP, mais les résultats ne semblent pas plus utiles que la simple recherche web.

Dépôt GitHub