Let's Encrypt support for our MCP Server

created: Saturday, Oct 18, 2025

Continuing our journey with our MCP documentation server from our previous post, we can dig a little deeper into the TLS story, and how we are generating the Let’s Encrypt certificate on startup.

If you want to see the full source code, the github repo is linked at the bottom of the post.

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 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(())

As you can see, the ACME protocol is rather straightforward. Now comes the tricky part of integrating this into our MCP server.

  1. On every start, we check for the existence of certs/{domain}.cert.pem and certs/{domain}.key.pem files, which means we already have valid certificates.
  2. If the files do not exist, we invoke the acme-client to generate the certificate and write it to those files.
  3. After that, we start our MCP server with a TLS config present.
// 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}");
    }
});

We also implemented a fallback for local runs: if there is no domain configuration present, we start the MCP server with plain HTTP. This makes local testing possible without the need for a certificate or DNS during development.

GitHub Repo