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 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.

Here is a general overview

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.

  1. En cada inicio, comprobamos la existencia de los archivos certs/{domain}.cert.pem y certs/{domain}.key.pem, lo que indica que ya tenemos certificados válidos.
  2. Si los archivos no existen, invocamos al acme-client para generar el certificado y escribirlo en dichos archivos.
  3. Después de eso, iniciamos nuestro servidor MCP con la 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 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.

GitHub Repo