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

created: Samstag, Okt. 18, 2025

Wir setzen unsere Reise mit unserem MCP-Dokumentationsserver aus unserem vorherigen Beitrag fort und können etwas tiefer in die TLS-Geschichte eintauchen und wie wir das Let’s Encrypt Zertifikat beim Start generieren.

Wenn Sie den kompletten Quellcode sehen wollen, ist das Github-Repo am Ende des Beitrags verlinkt.

Schauen wir uns also die Details etwas genauer an. Als wir versuchten, unseren MCP Server in ChatGPT und Gemini zu integrieren, tauchte die Anforderung für TLS auf. Da wir auch bei DownToZero überall Let’s Encrypt verwenden, haben wir den Prozess in diesem Container größtenteils wiederverwendet.

Hier ist eine allgemeine Übersicht

sequenceDiagram
  participant App as get_certificate()
  participant LE as Let's Encrypt (ACME)
  participant Axum as Axum HTTP Server
  participant FS as Dateisystem

  App->>LE: Account erstellen (NewAccount)
  App->>LE: Neue Bestellung anlegen (Identifier::Dns(domain))
  LE-->>App: Bestellung (Status = Pending)
  App->>App: Einmaligen Kanal erzeugen (snd, rcv)

  App->>LE: Autorisierungen abrufen
  LE-->>App: Autorisierung inkl. HTTP-01 Challenge
  App->>App: Token + key_authorization (geheim) berechnen

  App->>Axum: Server auf acme_port starten und<br/>/.well-known/acme-challenge/{token} -> geheim bereitstellen
  App->>App: 2 Sekunden warten
  App->>LE: challenge.set_ready()

  LE->>Axum: GET /.well-known/acme-challenge/{token}
  Axum-->>LE: 200 geheim (key_authorization)

  App->>LE: poll_ready (mit Backoff)
  LE-->>App: Bestellung bereit

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

  App->>FS: schreibe certs/{domain}.cert.pem
  App->>FS: schreibe certs/{domain}.key.pem
  App->>Axum: snd.send() (geordnetes Herunterfahren)
  App-->>App: Ok(())

Wie Sie sehen, ist das ACME-Protokoll recht einfach gehalten. Jetzt kommt der knifflige Teil, das 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, was bedeutet, dass wir bereits gültige Zertifikate besitzen.
  2. Falls die Dateien nicht vorhanden sind, rufen wir den acme-client auf, um das Zertifikat zu generieren und in diese Dateien zu schreiben.
  3. Anschließend starten wir unseren MCP Server mit einer TLS-Konfiguration.
// Zertifikat und Schlüssel aus Datei laden
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!("höre auf 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 wurde mit Fehler beendet, {e}");
    }
});

Wir haben auch eine Fallback-Option für lokale Läufe implementiert: Wenn keine Domain-Konfiguration vorliegt, starten wir den MCP Server mit einfachem HTTP. Das ermöglicht lokale Tests ohne Zertifikat oder DNS während der Entwicklung.

GitHub Repo