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

created: Samstag, Okt. 18, 2025

In Fortsetzung unserer Reise mit unserem MCP-Dokumentationsserver aus unserem vorherigen Beitrag, können wir ein wenig tiefer in die TLS-Geschichte eintauchen und wie wir das Let’s Encrypt-Zertifikat beim Start erzeugen.

Wenn Sie den vollständigen Quellcode sehen möchten, ist das GitHub-Repo unten im Beitrag verlinkt.

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

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: Erzeuge oneshot-Kanal (snd, rcv)

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

  App->>Axum: Server auf acme_port starten und bereitstellen<br/>/.well-known/acme-challenge/{token} -> secret
  App->>App: 2s schlafen
  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: schreibe certs/{domain}.cert.pem
  App->>FS: schreibe certs/{domain}.key.pem
  App->>Axum: snd.send() (graceful shutdown)
  App-->>App: Ok(())

Wie Sie sehen, ist das ACME-Protokoll recht geradlinig. Nun kommt der knifflige Teil: die Integration in unseren MCP-Server.

  1. Bei jedem Start prüfen wir das Vorhandensein der Dateien certs/{domain}.cert.pem und certs/{domain}.key.pem, was bedeutet, dass wir bereits gültige Zertifikate haben.
  2. Falls die Dateien nicht existieren, rufen wir den acme-client auf, um das Zertifikat zu erstellen und in diese Dateien zu schreiben.
  3. Danach starten wir unseren MCP-Server mit einer vorhandenen TLS-Konfiguration.
// Laden von Zertifikat und Schlüssel aus Datei
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 einen Fallback für lokale Ausführungen implementiert: Wenn keine Domain-Konfiguration vorhanden ist, starten wir den MCP-Server mit einfachem HTTP. Das ermöglicht lokale Tests ohne die Notwendigkeit eines Zertifikats oder DNS während der Entwicklung.

GitHub-Repo