Comme projet ludique, nous voulions exposer notre documentation officielle — qui est également hébergée ici — sous forme de 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és à vérifier les connaissances architecturales via un point d’accès public facile à consommer.
Partant d’une approche très naïve, nous avons établi le plan suivant pour implémenter cela.
Pour un bref aperçu, 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
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 les LLM, cela gaspille beaucoup de ressources tout en ayant un impact minimal sur la qualité du résultat. Mais surtout les LLM sont très performants pour lire et comprendre le Markdown. Il est donc devenu clair que nous voulions alimenter les 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 et même des formats arbitraires, nous avons commencé à créer une sortie JSON. L’idée était de rendre toutes les pages que nous avons en un seul gros JSON et voir ce que cela donne.
[
{
"contents": "Nous visons toujours ..",
"permalink": "https://downtozero.cloud/posts/2025/scale-to-zero-postgres/",
"title": "Bases de données postgresql Scale-To-Zero"
},
{
"contents": "Éliminer les cycles gaspillés dans le déploiement chez DownToZero, ...",
"permalink": "https://downtozero.cloud/posts/2025/github-deployment/",
"title": "Déploiements fluides avec l’action GitHub DTZ"
},
Maintenant, pour créer quelque chose comme ça, Hugo doit avoir 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 }}
{{- /* commence avec une map vide */ -}}
{{- $page := dict -}}
{{- /* toujours présent */ -}}
{{- $page = merge $page (dict
"title" .Title
"permalink" .Permalink) -}}
{{- /* ajoute les clefs optionnelles seulement quand elles ont du contenu */ -}}
{{- 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 dans le fichier config.toml.
baseURL = 'https://downtozero.cloud/'
title = 'Down To Zero'
[outputs]
home = ["HTML", "RSS", "JSON"]
Maintenant que nous avons le JSON généré, il devient disponible sur le site via /index.json.
Récupérer le contenu le plus à jour est devenu facile, ce qui a mené à la question ouverte d’implémenter la recherche. Puisque nous construisons toute notre stack sur des containers serverless, avec un backend principalement en Rust, notre choix s’est aussi porté sur un container 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 particuliers et 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 la plus intéressante : le serveur MCP.
Puisque tous nos services sont construits en Rust, nous avons fixé comme objectif de construire ce service en Rust. Heureusement, le projet MCP dispose d’une implémentation de référence Rust pour les clients et serveurs.
Nous avons suivi l’exemple à la lettre et avons rapidement fait fonctionner un serveur MCP en local.
Voici le dépôt complet GitHub pour tous ceux qui veulent entrer dans les détails.
https://github.com/DownToZero-Cloud/dtz-docs-mcp
Nous avons ensuite voulu déployer ce serveur MCP et avons rapidement reçu l’erreur des clients LLM indiquant que les serveurs MCP distants ne sont supportés que via TLS. Cela n’a pas rendu notre expérience plus facile.
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 beaucoup d’ajustements à faire.
Nous ferons un post séparé pour décrire en détail comment faire fonctionner Let's Encrypt dans une configuration serveur axum.
Support Let’s Encrypt pour notre serveur MCP
Pour conclure, nous avons réussi à faire tourner 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 a des réactions très différentes à cela. 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 clairement comment ou 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 pertinents que la simple recherche web.