Continuando nuestro recorrido 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 estamos generando el certificado Let’s Encrypt al arrancar.
Si quieres ver el código fuente completo, el repositorio de github está enlazado al final de la publicación.
Entonces, veamos los detalles un poco más de cerca. Cuando intentamos integrar nuestro servidor MCP con ChatGPT y Gemini, surgió el requisito de TLS. Como también usamos Let’s Encrypt en toda DownToZero, reutilizamos principalmente el proceso en este contenedor.
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 integrarlo en nuestro servidor MCP.
certs/{domain}.cert.pem y certs/{domain}.key.pem, lo que significa 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 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.