Let's Encrypt support for our MCP Server

created: sabato, ott 18, 2025

Continuando il nostro viaggio con il server di documentazione MCP dal nostro previous post, possiamo approfondire un po’ la storia del TLS e come generiamo il certificato Let’s Encrypt all’avvio.

Se vuoi vedere il codice sorgente completo, il repository github è collegato in fondo al post.

Quindi diamo un’occhiata più da vicino ai dettagli. Quando abbiamo cercato di integrare il nostro server MCP in ChatGPT e Gemini, è emersa la necessità del TLS. Poiché usiamo Let’s Encrypt in molti posti su DownToZero, abbiamo in gran parte riutilizzato lo stesso processo in questo container.

Here is a general overview

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: Crea account (NewAccount)
  App->>LE: Crea nuovo ordine (Identifier::Dns(domain))
  LE-->>App: Ordine (status = Pending)
  App->>App: Crea canale oneshot (snd, rcv)

  App->>LE: Recupera autorizzazioni
  LE-->>App: Autorizzazione incl. sfida HTTP-01
  App->>App: Calcola token + key_authorization (segreto)

  App->>Axum: Avvia server su acme_port servendo<br/>/.well-known/acme-challenge/{token} -> segreto
  App->>App: Attendi 2s
  App->>LE: challenge.set_ready()

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

  App->>LE: poll_ready (con backoff)
  LE-->>App: Ordine pronto

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

  App->>FS: scrivi certs/{domain}.cert.pem
  App->>FS: scrivi certs/{domain}.key.pem
  App->>Axum: snd.send() (spegnimento ordinato)
  App-->>App: Ok(())

Come puoi vedere, il protocollo ACME è piuttosto lineare. Ora viene la parte complicata: integrare tutto questo nel nostro server MCP.

  1. Ad ogni avvio, verifichiamo l’esistenza dei file certs/{domain}.cert.pem e certs/{domain}.key.pem, il che significa che abbiamo già certificati validi.
  2. Se i file non esistono, invochiamo l’acme-client per generare il certificato e scriverlo in quei file.
  3. Dopo di che, avviamo il nostro server MCP con una configurazione TLS presente.
// 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}");
    }
});

Abbiamo anche implementato un fallback per esecuzioni locali: se non è presente una configurazione di dominio, avviamo il server MCP con HTTP semplice. Questo rende possibile il testing locale senza la necessità di un certificato o del DNS durante lo sviluppo.

GitHub Repo