Support Let's Encrypt pour notre serveur MCP

created: samedi, oct. 18, 2025

Poursuivant notre aventure avec notre serveur de documentation MCP depuis 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 de 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 est apparue. Puisque nous utilisons également Let’s Encrypt partout dans DownToZero, nous avons principalement réutilisé le processus dans ce conteneur.

Voici une vue d’ensemble générale

sequenceDiagram
  participant App as get_certificate()
  participant LE as Let's Encrypt (ACME)
  participant Axum as Axum HTTP Server
  participant FS as Filesystem

  App->>LE: Create Account (NewAccount)
  App->>LE: Create New Order (Identifier::Dns(domain))
  LE-->>App: Order (status = Pending)
  App->>App: Create oneshot channel (snd, rcv)

  App->>LE: Fetch authorizations
  LE-->>App: Authorization incl. HTTP-01 challenge
  App->>App: Compute token + key_authorization (secret)

  App->>Axum: Start server on acme_port serving<br/>/.well-known/acme-challenge/{token} -> secret
  App->>App: Sleep 2s
  App->>LE: challenge.set_ready()

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

  App->>LE: poll_ready (with backoff)
  LE-->>App: Order Ready

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

  App->>FS: write certs/{domain}.cert.pem
  App->>FS: write certs/{domain}.key.pem
  App->>Axum: snd.send() (graceful shutdown)
  App-->>App: Ok(())

Comme vous pouvez le voir, le protocole ACME est assez simple. Vient 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 avons déjà des certificats valides.
  2. Si les fichiers n’existent pas, nous lançons le client acme pour générer le certificat et l’écrire dans ces fichiers.
  3. Ensuite, nous démarrons notre serveur MCP avec une configuration TLS présente.
// chargement du certificat et de la clé depuis un 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é une solution de secours pour les exécutions locales : s’il n’y a pas de configuration de domaine présente, nous lançons le serveur MCP en HTTP simple. Cela rend les tests locaux possibles sans avoir besoin de certificat ni de DNS pendant le développement.

GitHub Repo