Construire un serveur MCP pour la documentation des services

created: vendredi, oct. 10, 2025

En tant que projet amusant, nous voulions exposer notre documentation officielle — qui est également hébergée ici — sous forme d’un 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 via un point d’accès public facile à consommer.

Partant d’un début très naïf, nous avons élaboré le plan suivant pour mettre cela en œuvre.

Voici en teaser ce que nous souhaitons 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

Steps

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

Step 1 - Export the website

Le site actuel est construit avec Hugo. Tout le contenu de ce site est donc créé en Markdown puis rendu en HTML. Pour les navigateurs, le HTML est un bon format. Pour les moteurs de recherche et les LLM, cela gaspillerait pas mal de ressources tout en ayant un impact minimal sur la qualité du résultat. Mais surtout, les LLM sont très bons pour lire et comprendre le Markdown. Il est donc devenu évident que nous voulions alimenter le LLM avec du Markdown. En même temps, nous avions besoin de consommer ces données pour notre serveur de recherche.

Comme Hugo supporte plusieurs formats de sortie, y compris des formats arbitraires, nous avons commencé à construire une sortie JSON. L’idée était de rendre toutes les pages que nous avons dans un seul gros JSON et voir ce que cela donnerait.

[
  {
    "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 cela, Hugo doit disposer d’un template en place. Nous avons donc mis 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 faire rendre ce template, nous avons dû ajouter la sortie JSON dans le config.toml.

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

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

Step 2 - Consume and search

Maintenant que nous avons généré le JSON, il est accessible sur le site via /index.json.

Récupérer le contenu le plus récent est devenu facile, ce qui a mené à la question d’implémenter la recherche. Comme nous construisons tout notre stack sur des conteneurs serverless, avec un backend principalement en Rust, notre choix s’est porté aussi sur un conteneur Rust.

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

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 le contenu est couvert, continuons avec la partie plus intéressante : le serveur MCP.

Step 3 - Build the MCP server

Puisque tous nos services sont construits en Rust, nous avons fixé notre objectif de construire ce service en Rust. Heureusement, le projet MCP dispose d’une implémentation de référence Rust pour clients et serveurs.

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

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

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

Step 3.5 - complication inattendue

Nous voulions maintenant déployer ce serveur MCP et avons rapidement reçu une erreur depuis les clients LLM indiquant que les serveurs MCP distants ne sont supportés qu’avec TLS. Cela n’a pas facilité notre expérimentation.

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 avions déjà du code pour d’autres parties de la plateforme DTZ, nous n’avons pas eu besoin de trop d’ajustements.

Nous ferons un post supplémentaire décrivant en détail comment faire fonctionner Let's Encrypt dans une configuration serveur axum.

Support Let’s Encrypt pour notre serveur MCP

Final thoughts

En conclusion, nous avons mis en route 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 demande jamais d’informations supplémentaires, quel que soit la tâche. Gemini utilise le MCP si nécessaire. On ne sait pas vraiment comment ni quand il est invoqué, mais il utilise la source d’information disponible. ChatGPT n’utilise pas le MCP et retombe toujours sur sa propre fonction de recherche web, qui a la priorité sur le serveur MCP. En mode Recherche, ChatGPT utilise le MCP, mais les résultats ne semblent pas plus pertinents que la recherche web simple.

Github Repo