Continuando nuestro viaje con nuestro servidor de documentación MCP desde nuestra publicación anterior, podemos profundizar un poco más en la historia de TLS y cómo generamos el certificado de Let’s Encrypt al inicio.
Si quieres ver el código fuente completo, el repositorio de GitHub está enlazado al final de la entrada.
So let’s look a little closer at the details. When we tried to get our MCP server integrated into ChatGPT and Gemini, the requirement for TLS came up. Since we also use Let’s Encrypt all over DownToZero, we mostly reused the process in this container.
sequenceDiagram
participant App as get_certificate()
participant LE as Let's Encrypt (ACME)
participant Axum as Servidor HTTP Axum
participant FS as Sistema de archivos
App->>LE: Crear cuenta (NewAccount)
App->>LE: Crear nueva orden (Identifier::Dns(domain))
LE-->>App: Orden (status = Pending)
App->>App: Crear canal oneshot (snd, rcv)
App->>LE: Obtener autorizaciones
LE-->>App: Autorización incl. desafío HTTP-01
App->>App: Calcular token + key_authorization (secreto)
App->>Axum: Iniciar servidor en acme_port sirviendo<br/>/.well-known/acme-challenge/{token} -> secreto
App->>App: Esperar 2s
App->>LE: challenge.set_ready()
LE->>Axum: GET /.well-known/acme-challenge/{token}
Axum-->>LE: 200 secreto (key_authorization)
App->>LE: poll_ready (con backoff)
LE-->>App: Orden lista
App->>LE: finalize()
LE-->>App: private_key_pem
App->>LE: poll_certificate()
LE-->>App: cert_chain_pem
App->>FS: escribir certs/{domain}.cert.pem
App->>FS: escribir certs/{domain}.key.pem
App->>Axum: snd.send() (apagado ordenado)
App-->>App: Ok(())
Como se puede ver, el protocolo ACME es bastante sencillo. Ahora viene la parte complicada: integrarlo en nuestro servidor MCP.
certs/{domain}.cert.pem y certs/{domain}.key.pem, lo que indica que ya tenemos certificados válidos.// 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}");
}
});
También implementamos una solución alternativa para ejecuciones locales: si no hay una configuración de dominio presente, iniciamos el servidor MCP con HTTP simple. Esto permite pruebas locales sin necesidad de un certificado o DNS durante el desarrollo.