Soporte de Let's Encrypt para nuestro servidor MCP

created: sábado, oct 18, 2025

Continuando nuestro viaje con nuestro servidor de documentación MCP desde nuestra entrada anterior, podemos profundizar un poco más en la historia de TLS y en cómo generamos el certificado de Let’s Encrypt al arrancar.

Si quieres ver el código fuente completo, el repositorio de GitHub está enlazado al final de la entrada.

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

Aquí tienes 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: Create Account (NewAccount)
  App->>LE: Create New Order (Identifier::Dns(domain))
  LE-->>App: Order (status = Pending)
  App->>App: Create oneshot channel (snd, rcv)

  App->>LE: Fetch authorizations
  LE-->>App: Authorization incl. HTTP-01 challenge
  App->>App: Compute token + key_authorization (secret)

  App->>Axum: Start server on acme_port serving<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 (with backoff)
  LE-->>App: Order Ready

  App->>LE: finalize()
  LE-->>App: private_key_pem
  App->>LE: poll_certificate()
  LE-->>App: cert_chain_pem

  App->>FS: write certs/{domain}.cert.pem
  App->>FS: write certs/{domain}.key.pem
  App->>Axum: snd.send() (graceful shutdown)
  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 arranque, comprobamos 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 al cliente ACME para generar el certificado y escribirlo en esos archivos.
  3. Después de eso, arrancamos nuestro servidor MCP con una configuración 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}");
    }
});

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

GitHub Repo