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.
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.
certs/{domain}.cert.pem e certs/{domain}.key.pem, il che significa che abbiamo già certificati validi.// 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.