Soporte de Let's Encrypt para nuestro Servidor MCP

created: sábado, oct. 18, 2025

Continuando nuestro recorrido con nuestro servidor de documentación MCP de nuestra publicación anterior, podemos profundizar un poco más en la historia del TLS y cómo estamos generando el certificado Let’s Encrypt al iniciar.

Si deseas ver el código fuente completo, el repositorio de github está enlazado al final de la publicación.

Así que observemos un poco más de cerca los detalles. Cuando intentamos integrar nuestro servidor MCP en ChatGPT y Gemini, surgió el requisito de TLS. Como también usamos Let’s Encrypt en todo DownToZero, reutilizamos principalmente el proceso en este contenedor.

Aquí hay una visión general

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: Crear Cuenta (NewAccount)
  App->>LE: Crear Nuevo Pedido (Identifier::Dns(domain))
  LE-->>App: Pedido (estado = Pendiente)
  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: Dormir 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: Pedido Listo

  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() (cierre ordenado)
  App-->>App: Ok(())

Como puedes ver, el protocolo ACME es bastante sencillo. Ahora viene la parte complicada de integrar esto en nuestro servidor MCP.

  1. En cada inicio, verificamos la existencia de los archivos certs/{domain}.cert.pem y certs/{domain}.key.pem, lo que significa que ya tenemos certificados válidos.
  2. Si los archivos no existen, invocamos acme-client para generar el certificado y escribirlo en esos archivos.
  3. Después de eso, iniciamos nuestro servidor MCP con una configuración TLS presente.
// cargando certificado y clave desde archivo
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!("escuchando en 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!("servidor sse finalizado con error, {e}");
    }
});

También implementamos un mecanismo de respaldo para ejecuciones locales: si no hay una configuración de dominio presente, iniciamos el servidor MCP con HTTP simple. Esto hace posible la prueba local sin la necesidad de un certificado o DNS durante el desarrollo.

GitHub Repo