Prise en charge de Let's Encrypt pour notre serveur MCP

created: samedi, oct. 18, 2025

Poursuivant notre parcours avec notre serveur de documentation MCP depuis notre article précédent, nous pouvons approfondir un peu l’histoire du TLS et la façon dont 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 à ChatGPT et Gemini, la nécessité du TLS est apparue. Comme nous utilisons également Let’s Encrypt partout sur DownToZero, nous avons majoritairement réutilisé le processus dans ce conteneur.

Here is a general overview

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: Order (status = Pending)
  App->>App: Créer un canal oneshot (snd, rcv)

  App->>LE: Récupérer les authorizations
  LE-->>App: Authorization incl. HTTP-01 challenge
  App->>App: Calculer token + key_authorization (secret)

  App->>Axum: Démarrer le serveur sur acme_port servant<br/>/.well-known/acme-challenge/{token} -> secret
  App->>App: Pause 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: Order Ready

  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 gracieux)
  App-->>App: Ok(())

Comme vous pouvez le voir, le protocole ACME est assez simple. Vient maintenant la partie délicate : intégrer cela 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 client acme 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.
// loading cert and key from file
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!("listening on 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!("sse server shutdown with error, {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 démarrons le serveur MCP en HTTP simple. Cela permet des tests locaux sans avoir besoin d’un certificat ni de DNS pendant le développement.

Dépôt GitHub