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

created: Samstag, Okt. 18, 2025

Unsere Reise mit unserem MCP-Dokumentationsserver aus unserem vorherigen Beitrag fortsetzend, können wir etwas tiefer in die TLS-Thematik eintauchen und wie wir das Let’s Encrypt-Zertifikat beim Start generieren.

Wenn Sie den vollständigen Quellcode sehen möchten, 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, kam die Anforderung für TLS auf. Da wir Let’s Encrypt auch überall bei DownToZero verwenden, haben wir den Prozess in diesem Container größtenteils wiederverwendet.

Hier ein allgemeiner Überblick

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

Wie Sie sehen, ist das ACME-Protokoll recht unkompliziert. Nun kommt der knifflige Teil, dies in unseren MCP-Server zu integrieren.

  1. Bei jedem Start überprü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 generieren und in diese Dateien zu schreiben.
  3. Danach starten wir unseren MCP-Server mit der 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 eine Fallback-Lösung für lokale Runs implementiert: Wenn keine Domain-Konfiguration vorliegt, starten wir den MCP-Server mit einfachem HTTP. Dies ermöglicht lokale Tests ohne Zertifikat oder DNS während der Entwicklung.

GitHub Repo