Support Let's Encrypt pour notre serveur MCP

created: samedi, oct. 18, 2025

Poursuivant notre aventure avec notre serveur de documentation MCP de notre article précédent, nous pouvons approfondir un peu plus l’histoire du TLS, et comment nous générons le certificat Let’s Encrypt au démarrage.

Si vous souhaitez voir le code source complet, le dépôt github est lié en bas de l’article.

Regardons donc un peu plus près les détails. Lorsque nous avons essayé d’intégrer notre serveur MCP dans ChatGPT et Gemini, la nécessité du TLS s’est imposée. Comme nous utilisons également Let’s Encrypt partout sur DownToZero, nous avons principalement réutilisé ce processus dans ce conteneur.

Voici un aperçu général

sequenceDiagram
  participant App as get_certificate()
  participant LE as Let's Encrypt (ACME)
  participant Axum as Serveur HTTP Axum
  participant FS as Système de fichiers

  App->>LE: Créer un compte (NewAccount)
  App->>LE: Créer une nouvelle commande (Identifier::Dns(domain))
  LE-->>App: Commande (statut = Pending)
  App->>App: Créer un canal oneshot (snd, rcv)

  App->>LE: Récupérer les autorisations
  LE-->>App: Autorisation incl. défi HTTP-01
  App->>App: Calculer le token + key_authorization (secret)

  App->>Axum: Démarrer le serveur sur acme_port servant<br/>/.well-known/acme-challenge/{token} -> secret
  App->>App: Dormir 2s
  App->>LE: challenge.set_ready()

  LE->>Axum: GET /.well-known/acme-challenge/{token}
  Axum-->>LE: 200 secret (key_authorization)

  App->>LE: poll_ready (avec backoff)
  LE-->>App: Commande prête

  App->>LE: finalize()
  LE-->>App: private_key_pem
  App->>LE: poll_certificate()
  LE-->>App: cert_chain_pem

  App->>FS: écrire certs/{domain}.cert.pem
  App->>FS: écrire certs/{domain}.key.pem
  App->>Axum: snd.send() (arrêt en douceur)
  App-->>App: Ok(())

Comme vous pouvez le voir, le protocole ACME est assez simple. Voici maintenant la partie délicate de l’intégration dans notre serveur MCP.

  1. À chaque démarrage, nous vérifions l’existence des fichiers certs/{domain}.cert.pem et certs/{domain}.key.pem, ce qui signifie que nous disposons déjà de certificats valides.
  2. Si les fichiers n’existent pas, nous invoquons le acme-client pour générer le certificat et l’écrire dans ces fichiers.
  3. Après cela, nous démarrons notre serveur MCP avec une configuration TLS présente.
// chargement du certificat et de la clé depuis le fichier
let cert_file = format!("certs/{}.cert.pem", domain);
let key_file = format!("certs/{}.key.pem", domain);
let tls_config = RustlsConfig::from_pem_file(cert_file, key_file)
    .await
    .unwrap();
log::info!("écoute sur https://[::]:{}", config.port);

let sse_config = SseServerConfig {
    bind: format!("[::]:{}", config.port).parse().unwrap(),
    sse_path: "/sse".to_string(),
    post_path: "/message".to_string(),
    ct: tokio_util::sync::CancellationToken::new(),
    sse_keep_alive: None,
};
let (sse_server, router) = SseServer::new(sse_config);
let addr = sse_server.config.bind;

let ct = sse_server.with_service(DowntozeroTool::new);

let server = axum_server::bind_rustls(addr, tls_config).serve(router.into_make_service());

tokio::spawn(async move {
    if let Err(e) = server.await {
        log::error!("arrêt du serveur sse avec erreur, {e}");
    }
});

Nous avons également implémenté un fallback pour les exécutions locales : s’il n’y a pas de configuration de domaine présente, nous démarrons le serveur MCP en HTTP simple. Cela permet de tester localement sans besoin de certificat ni DNS durant le développement.

GitHub Repo