Ein ChatGPT-Widget für die E-Mail-Authentifizierungsprüfung mit CaptainDNS integrieren
Von CaptainDNS
Veröffentlicht am 7. Dezember 2025
- #MCP
- #ChatGPT
- #Apps SDK
- #DNS
- #Architektur

- Ziel: eine visuelle SPF/DKIM/DMARC/BIMI-Prüfung in ChatGPT anzeigen, betrieben vom CaptainDNS-MCP-Server.
- Praktisch müssen drei Dinge zusammenpassen: MCP-Tool-Descriptor, Tool-Response (
structuredContent+_meta) und eine HTML-Resourcetext/html+skybridge. - Haupt-Hürden: wo
openai/outputTemplatehingehört, die Form vonresources/readkorrigieren und das Timing vonwindow.openai.toolOutputim Widget abfangen. - Ergebnis: ein
email_auth_audit-Widget, das Score, Zusammenfassung und MX/SPF/DKIM/DMARC/BIMI-Checks direkt im ChatGPT-Dialog rendert, ohne das Reasoning zu verlieren.
1. Kontext: warum ein ChatGPT-Widget für CaptainDNS?
CaptainDNS bietet bereits eine API zur Prüfung der E-Mail-Authentifizierung: Wir berechnen einen "ready to send"-Score auf Basis von MX, SPF, DKIM, DMARC und BIMI für eine Domain.
Mit dem CaptainDNS-MCP-Server konnten wir dieses Audit schon über MCP Inspector und dann über eine ChatGPT-App auslösen. UX-seitig fehlte jedoch etwas:
- der Nutzer sah einen Textblock,
- aber keine synthetische Ansicht: Score, Status pro Mechanismus, Zusammenfassung.
Das neue Apps SDK UI von OpenAI erlaubt es, einem Tool ein HTML-Template zuzuordnen, um eine Komponente im Dialog zu rendern. Die Idee war simpel:
Wenn ChatGPT
email_auth_auditaufruft, soll ein CaptainDNS-Widget die Prüfung zusammenfassen und der Assistent die DNS-Korrekturen ausformulieren.
Die Realität war etwas subtiler.
Dieser Beitrag beschreibt, was ChatGPT genau erwartet, welche Fallstricke wir hatten und wie wir schließlich das folgende Rendering erhielten:
(siehe den Abschnitt "Ergebnis: das Widget in Aktion" für den Screenshot, als Bild einzubinden).
2. Gesamtarchitektur: Backend, MCP, Apps SDK
Auf CaptainDNS-Seite ist die Basisarchitektur:
- Frontend: Next.js auf Vercel (
captaindns.com). - Backend-API: Go auf Fly.io, die die Fachlogik abbildet (DNS-Audit, Scoring usw.).
- MCP-Server: ebenfalls Go auf Fly.io, spricht JSON-RPC mit MCP Inspector / ChatGPT und delegiert ans Backend.
Für das Widget kommt ein neuer Akteur hinzu: die ChatGPT-App, die den CaptainDNS-MCP konsumiert und ein HTML-Resource text/html+skybridge rendern kann.
Vereinfachter Ablauf für email_auth_audit:
- Der Nutzer fragt ChatGPT: "Kannst du das Mail-Setup von captaindns.com prüfen?"
- Das Modell entscheidet, das MCP-Tool
email_auth_auditaufzurufen. - Der MCP-Server ruft die Backend-API, erhält das Audit-JSON und mappt es auf
structuredContent+_meta. - ChatGPT rendert:
- einen Textblock basierend auf
content+structuredContent, - ein Widget, indem
ui://widget/email-auth-widget.htmlgeladen wird, referenziert überopenai/outputTemplate.
- einen Textblock basierend auf
Diesen Ablauf kann man sich mit einem Diagramm so vorstellen:

3. Was ChatGPT wirklich von einem MCP-Tool „Widget“ erwartet
Die erste Herausforderung war zu verstehen, wo man was deklariert. Auf Apps-SDK-Seite gibt es drei klar getrennte Ebenen:
- Tool-Descriptor (
tools/list)
- Hier stehen die OpenAI-Metadaten:
_meta["openai/outputTemplate"] = "ui://widget/email-auth-widget.html",_meta["openai/toolInvocation/invoking"]/invoked(die kleinen Meldungen über dem Widget).
- Parallel kann man
annotationsfür eigene Zwecke behalten (Tags, OAuth2-Scopes usw.).
- HTML-Resource (
resources/list+resources/read)
- URI:
ui://widget/email-auth-widget.html. mimeType: strikttext/html+skybridge.- Der Inhalt ist ein klassisches HTML mit einem
<script type="module">, daswindow.openailiest.
- Tool-Response (
tools/call)
structuredContent: die Rohdaten, mit denen das Modell argumentiert._meta: die ergebnisspezifischen Metadaten, im Widget sichtbar.content: ein Text-Fallback für den Dialog.
Für das email_auth_audit-Widget haben wir folgende Konvention gewählt:
structuredContent= komplettes Audit-JSON (Domain, Score, Checks, Breakdown, Notes...)._meta.emailAuthUi= die "UI-taugliche" Version: Score, Zusammenfassung, Rating (good/ok/bad), normalisierte Checks._meta["openai/outputTemplate"]+openai/toolInvocation/*= sowohl in:- dem Tool-Descriptor,
- als auch in der Tool-Response (zur Sicherheit – einige Apps-SDK-Guides empfehlen beides).
Diese Trennung erlaubt es:
- dem Modell, die Daten ohne UI-Sicht zu lesen,
- dem Widget, ein bereits "renderfertiges" Payload abzuholen.
4. Das Tool email_auth_audit auf MCP-Seite verdrahten
Auf MCP-Seite tut der Handler email_auth_audit drei Dinge:
- Backend aufrufen mit der Domain und ein Audit-JSON holen, z. B.:
{
"domain": "captaindns.com",
"score": 57,
"max_score": 100,
"percentage": 57,
"authentication_summary": "Teilweise authentifiziert: SPF/DKIM/DMARC verstärken.",
"checks": [ ... ],
"score_breakdown": { ... },
"findings": { ... },
"notes": [ ... ],
"ui_meta": { ... }
}
- Auf
structuredContentremappen
Hier verwenden wir das JSON fast unverändert weiter:
"structuredContent": {
"domain": "captaindns.com",
"ready_to_send": true,
"degraded": true,
"grade": "mittel",
"score": 57,
"max_score": 100,
"percentage": 57,
"authentication_score": 57,
"authentication_summary": "Teilweise authentifiziert: SPF/DKIM/DMARC verstärken.",
"checks": [ ... ],
"score_breakdown": { ... },
"findings": { ... },
"notes": [ ... ]
}
_meta+contentbauen
"_meta": {
"emailAuthUi": {
"score": 57,
"summary": "Teilweise authentifiziert: SPF/DKIM/DMARC verstärken.",
"rating": "bad",
"checks": [ ... ]
},
"openai/outputTemplate": "ui://widget/email-auth-widget.html",
"openai/toolInvocation/invoking": "E-Mail-Authentifizierungs-Audit läuft...",
"openai/toolInvocation/invoked": "E-Mail-Authentifizierungs-Audit abgeschlossen."
},
"content": [
{
"type": "text",
"text": "Authentifizierungs-Score: 57/100 — Teilweise authentifiziert: SPF/DKIM/DMARC verstärken."
}
]
Der "Reasoning"-Teil von ChatGPT stützt sich auf structuredContent und content. Das Widget nutzt structuredContent und _meta.emailAuthUi.
5. Das HTML-Template als MCP-Resource exponieren
Als Nächstes musste das Template auf MCP-Seite deklariert werden.
5.1. resources/list: die Resources deklarieren
Der MCP stellt drei Resources für dieses Widget bereit:
ui://widget/email-auth-widget.html(HTML + CSS + JS),ui://widget/apps-sdk-bridge.js(optional, Apps-SDK-Bridge),- ggf. weitere Assets.
resources/list liefert ein Array von Deskriptoren mit uri, name, description, mimeType.
5.2. resources/read: die invalid_union-Falle
Die erste Version von resources/read gab ein JSON in dieser Form zurück:
{
"contents": [
{
"uri": { "value": "ui://widget/email-auth-widget.html" }, // falsch
"mimeType": "text/html+skybridge",
"text": "<!doctype html>..."
}
]
}
Ergebnis im MCP Inspector: ein hübscher invalid_union-Fehler auf contents[0].uri.
Der MCP-Client erwartet genau:
{
"contents": [
{
"uri": "ui://widget/email-auth-widget.html",
"mimeType": "text/html+skybridge",
"text": "<!doctype html>..."
}
]
}
Nachdem die Typen korrigiert waren (uri und text als Strings), konnte MCP Inspector das HTML anzeigen und ChatGPT das Template ohne Murren laden.
6. Im Widget: window.openai verstehen
Letzter Schritt – und nicht der kleinste: die Widget-Implementierung.
6.1. Was ChatGPT dem Template liefert
In einem Apps-SDK-Template injiziert ChatGPT ein globales Objekt window.openai, dessen Felder sich im Laufe der Zeit ändern:
- beim Laden kann
window.openai.toolOutputnulloder leer sein; - wenn das Tool ausgeführt wird, sendet ChatGPT ein
openai:set_globals-Event und aktualisiert:globals.toolOutput,globals.toolResponseMetadata.
Zu Beginn nahm unser Script an, dass window.openai.toolOutput sofort ein Objekt vom Typ structuredContent enthält. Das war falsch, daher ein Render mit:
Unbekannte Domain,Score nicht verfügbar,Keine Checks verfügbar.
6.2. Die Form von toolOutput normalisieren
Wir haben zuerst einen Helper geschrieben, der mehrere mögliche Formen akzeptiert:
function extractStructured(toolOutputSource) {
if (!toolOutputSource) return {};
// Fall 1: direktes structuredContent
if (toolOutputSource.domain || toolOutputSource.score || toolOutputSource.checks) {
return toolOutputSource;
}
// Fall 2: { structuredContent: {...} }
if (toolOutputSource.structuredContent) {
return toolOutputSource.structuredContent;
}
// Fall 3: { result: { structuredContent: {...} } }
if (toolOutputSource.result?.structuredContent) {
return toolOutputSource.result.structuredContent;
}
return toolOutputSource;
}
Dann tun wir in normalize statt:
const structured =
toolOutputSource?.structuredContent ?? toolOutputSource ?? {};
folgendes:
const structured = extractStructured(toolOutputSource);
6.3. Timing mit openai:set_globals handhaben
Anschließend haben wir das Widget an das openai:set_globals-Event angebunden:
function renderFromContext() {
const ctx = window.openai || {};
const toolOutput = ctx.toolOutput || ctx.toolInvocationResult || {};
const meta = ctx.toolResponseMetadata || {};
const data = normalize(toolOutput, meta);
render(data);
}
// Erster, optimistischer Render
renderFromContext();
// Reaktiver Render, wenn ChatGPT Tool-Ergebnisse pusht
window.addEventListener(
"openai:set_globals",
(event) => {
const globals = event.detail?.globals || {};
const toolOutput = globals.toolOutput ?? window.openai?.toolOutput ?? {};
const meta = globals.toolResponseMetadata ?? window.openai?.toolResponseMetadata ?? {};
const data = normalize(toolOutput, meta);
render(data);
},
{ passive: true }
);
Mit diesen beiden Punkten zeigte das Widget:
captaindns.comoben links,57/100 (57%)oben rechts,- die Zusammenfassung sowie die Zeilen MX/SPF/DKIM/DMARC/BIMI mit ihren Status.
7. Ergebnis: das Widget in Aktion
Die finale Ansicht wirkt wie eine CaptainDNS-Karte direkt im Dialog:
- Titel:
captaindns.com. - Score:
57/100 (57%)mit Statusfarbe (badin unserem Fall). - Zusammenfassung: "Teilweise authentifiziert: SPF/DKIM/DMARC verstärken."
- Tabelle:
- MX: OK –
1 smtp.google.com. - SPF: OK –
v=spf1 include:_spf.google.com -all - DKIM: Info – "Kein Eintrag gefunden"
- DMARC: OK –
v=DMARC1; p=reject; rua=mailto:dmarc@captaindns.com; - BIMI: Warnung – "Kein Eintrag gefunden"
- MX: OK –

8. Stolpersteine und Checklist
Zur Erinnerung die Checklist, die wir jetzt für jedes neue Apps-SDK-Widget am CaptainDNS-MCP nutzen:
- Tool-Descriptor (
tools/list):
_meta["openai/outputTemplate"]zeigt auf eine HTML-Resourcetext/html+skybridge._meta["openai/toolInvocation/invoking"]/invokedsind gesetzt.
- HTML-Resource (
resources/read):
contents[0].uriist ein String (exakt die Resource-URI).contents[0].mimeType === "text/html+skybridge".contents[0].textenthält valides HTML.
- Tool-Response (
tools/call):
structuredContentträgt die Fachdaten._metaenthält:- die UI-Metadaten (hier
emailAuthUi), - die
openai/*-Keys, falls man die Deklaration doppeln will. contententhält einen lesbaren Satz für den Dialog.
- die UI-Metadaten (hier
- HTML-Widget:
- liest
window.openai.toolOutput/toolResponseMetadata, - normalisiert die Form von
toolOutput, - lauscht auf
openai:set_globals, - lässt kein Debug-Rauschen im finalen Render.
Mit diesen Sicherungen wird das Hinzufügen neuer Widgets (z. B. für autoritative DNS-Gesundheit oder Propagation) deutlich reibungsloser.
9. Wie geht es weiter?
Dieses erste email_auth_audit-Widget dient als Referenz für kommende CaptainDNS-UI-Komponenten in ChatGPT:
- ein Widget für die autoritative DNS-Gesundheit (
domain_dns_check), - ein Multi-Resolver-DNS-Propagation-Widget,
- oder sogar umfangreichere Dashboards, die mehrere Tools kombinieren.
Der wichtigste Punkt aus diesem REX ist eigentlich einfach:
das Apps-SDK-Rendering ist nicht "magisch"; es beruht auf einem sehr präzisen Vertrag zwischen Tool-Descriptor, Tool-Response und den HTML-Resources.
Wenn man diesen Vertrag beherrscht, kann man ziemlich rohe MCP-Tools in wesentlich lesbarere Nutzererlebnisse direkt im ChatGPT-Dialog verwandeln, ohne die Business-Logik im Frontend zu duplizieren.
