Let's Encrypt-Unterstützung für unseren MCP-Server

created: Samstag, Okt. 18, 2025

Continuing our journey with our MCP documentation server from our vorherigen Beitrag, 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: Konto erstellen (NewAccount)
  App->>LE: Neue Order erstellen (Identifier::Dns(domain))
  LE-->>App: Order (status = Pending)
  App->>App: oneshot channel erstellen (snd, rcv)

  App->>LE: Authorisierungen abrufen
  LE-->>App: Authorization inkl. HTTP-01 challenge
  App->>App: Token + key_authorization berechnen (secret)

  App->>Axum: Starte Server auf acme_port und bediene<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 (mit 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(())

Wie Sie sehen können, ist das ACME-Protokoll ziemlich geradlinig. Schwieriger wird es, dies in unseren MCP-Server zu integrieren.

  1. Bei jedem Start prüfen wir, ob die Dateien certs/{domain}.cert.pem und certs/{domain}.key.pem existieren — das bedeutet, dass wir bereits gültige Zertifikate haben.
  2. Existieren die Dateien nicht, rufen wir den acme-client auf, um das Zertifikat zu erzeugen und in diese Dateien zu schreiben.
  3. Danach starten wir unseren MCP-Server mit einer vorhandenen TLS-Konfiguration.
// 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}");
    }
});

Wir haben außerdem eine Fallback-Lösung für lokale Ausführungen implementiert: Falls keine Domain-Konfiguration vorhanden ist, starten wir den MCP-Server mit einfachem HTTP. Das ermöglicht lokale Tests ohne Zertifikat oder DNS während der Entwicklung.

GitHub Repo