Supporto Let's Encrypt per il nostro server MCP

created: sabato, ott 18, 2025

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

Se vuoi vedere il codice sorgente completo, il repository GitHub è linkato in fondo al post.

Quindi diamo un’occhiata più da vicino ai dettagli. Quando abbiamo provato a integrare il nostro server MCP in ChatGPT e Gemini, è emersa la necessità del TLS. Poiché usiamo Let’s Encrypt in tutto DownToZero, abbiamo riutilizzato per lo più lo stesso 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. HTTP-01 challenge
  App->>App: Calcola token + key_authorization (segreto)

  App->>Axum: Avvia server su acme_port servendo<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 (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: scrive certs/{domain}.cert.pem
  App->>FS: scrive certs/{domain}.key.pem
  App->>Axum: snd.send() (chiusura graduale)
  App-->>App: Ok(())

Come puoi vedere, il protocollo ACME è piuttosto lineare. Ora arriva la parte più complessa: integrarlo 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 ciò, avviamo il 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 le esecuzioni locali: se non è presente una configurazione di dominio, avviamo il server MCP con HTTP semplice. Questo rende i test locali possibili senza la necessità di un certificato o del DNS durante lo sviluppo.

Repository GitHub