Supporto Let's Encrypt per il nostro MCP Server

created: sabato, ott 18, 2025

Continuando il nostro percorso con il server di documentazione MCP dal nostro post precedente, possiamo approfondire un po’ di più la questione TLS e come generiamo il certificato Let’s Encrypt all’avvio.

Se vuoi vedere il codice sorgente completo, il repo di github è linkato in fondo al post.

Diamo quindi uno sguardo più ravvicinato ai dettagli. Quando abbiamo provato a integrare il nostro server MCP in ChatGPT e Gemini, è emerso il requisito TLS. Poiché usiamo anche Let’s Encrypt ampiamente su DownToZero, abbiamo riutilizzato in gran parte quel processo in questo container.

Ecco una panoramica generale

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

Come puoi vedere, il protocollo ACME è piuttosto semplice. Ora arriva la parte delicata dell’integrazione nel nostro server MCP.

  1. Ad ogni avvio, controlliamo 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 il client ACME per generare il certificato e scriverlo in quei file.
  3. Dopo di ciò, avviamo il nostro server MCP con una configurazione TLS presente.
// caricamento certificato e chiave da 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!("in ascolto su 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!("arresto del server sse con errore, {e}");
    }
});

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

GitHub Repo