Intégrer un widget ChatGPT pour l'audit d'authentification des e-mails avec CaptainDNS
Par CaptainDNS
Publié le 7 décembre 2025
- #MCP
- #ChatGPT
- #Apps SDK
- #DNS
- #Architecture

- Objectif : afficher un composant visuel d'audit SPF/DKIM/DMARC/BIMI dans ChatGPT, piloté par le serveur MCP de CaptainDNS.
- En pratique, il faut aligner trois choses : le descriptor du tool MCP, la réponse du tool (
structuredContent+_meta) et une resource HTMLtext/html+skybridge. - Les difficultés principales : comprendre où placer
openai/outputTemplate, corriger le format deresources/readet gérer le timing dewindow.openai.toolOutputdans le widget. - Résultat : un widget
email_auth_auditqui rend la note, le résumé et les checks MX/SPF/DKIM/DMARC/BIMI directement dans la conversation ChatGPT... sans perdre la partie "raisonnement" de l'assistant.
1. Contexte : pourquoi un widget ChatGPT pour CaptainDNS ?
CaptainDNS propose déjà une API d'audit d'authentification email : on calcule un score de préparation à l'envoi à partir de MX, SPF, DKIM, DMARC et BIMI pour un domaine donné.
Avec l'arrivée du serveur MCP CaptainDNS, nous pouvions déjà appeler cet audit depuis MCP Inspector, puis depuis une App ChatGPT. Côté UX, il manquait cependant quelque chose :
- l'utilisateur voyait un bloc de texte,
- mais pas de vue synthétique : score, statut par mécanisme, résumé.
Le nouveau Apps SDK UI d'OpenAI permet justement de brancher un template HTML sur un tool pour rendre un composant dans la conversation. L'idée était donc simple :
Quand ChatGPT appelle
email_auth_audit, afficher un widget CaptainDNS qui résume l'audit, puis laisser l'assistant détailler les corrections DNS à appliquer.
La réalité a été un peu plus subtile.
Ce billet raconte ce que ChatGPT attend exactement, les pièges rencontrés, et comment nous avons fini par obtenir le rendu suivant :
(voir section "Résultat : le widget en action" pour la capture d'écran, à intégrer sous forme d'image).
2. Architecture globale : backend, MCP, Apps SDK
Côté CaptainDNS, l'architecture de base est la suivante :
- Frontend : Next.js sur Vercel (
captaindns.com). - Backend API : Go sur Fly.io, qui fait tout le métier (audit DNS, scoring, etc.).
- Serveur MCP : Go sur Fly.io également, qui parle JSON-RPC avec MCP Inspector / ChatGPT et délègue au backend.
Pour le widget, on ajoute un nouvel acteur : l'App ChatGPT qui consomme le MCP CaptainDNS et sait rendre une resource HTML text/html+skybridge.
Le flux simplifié pour email_auth_audit :
- L'utilisateur demande à ChatGPT : "Peux-tu auditer la messagerie de captaindns.com ?"
- Le modèle décide d'appeler le tool MCP
email_auth_audit. - Le serveur MCP appelle l'API backend, reçoit le JSON d'audit et le "re-map" en
structuredContent+_meta. - ChatGPT rend :
- un bloc texte basé sur
content+structuredContent, - un widget en chargeant
ui://widget/email-auth-widget.htmlréférencé paropenai/outputTemplate.
- un bloc texte basé sur
Vous pouvez visualiser ce flux avec un diagramme du type :

3. Ce que ChatGPT attend vraiment d'un tool MCP "widget"
La première difficulté a été de comprendre où déclarer quoi. Côté Apps SDK, il y a trois couches bien distinctes :
- Descriptor du tool (
tools/list)
- C'est là qu'on déclare les métadonnées OpenAI :
_meta["openai/outputTemplate"] = "ui://widget/email-auth-widget.html",_meta["openai/toolInvocation/invoking"]/invoked(les petits messages au-dessus du widget).
- On peut garder en parallèle des
annotationspour nos propres besoins (tags, scopes OAuth2, etc.).
- Resource HTML (
resources/list+resources/read)
- URI :
ui://widget/email-auth-widget.html. mimeType: impérativementtext/html+skybridge.- Le contenu est un fichier HTML classique, avec un
<script type="module">qui litwindow.openai.
- Réponse de tool (
tools/call)
structuredContent: les données brutes que le modèle va utiliser pour raisonner._meta: les métadonnées spécifiques au résultat, visibles depuis le widget.content: une version texte "fallback" pour la conversation.
Pour le widget email_auth_audit, nous avons adopté la convention suivante :
structuredContent= JSON complet de l'audit (domaine, score, checks, breakdown, notes...)._meta.emailAuthUi= version "UI-friendly" : score, résumé, rating (good/ok/bad), checks normalisés._meta["openai/outputTemplate"]+openai/toolInvocation/*= présents à la fois dans :- le descriptor du tool,
- et la réponse du tool (par sécurité, certains guides Apps SDK recommandent les deux).
Cette séparation permet :
- au modèle de lire les données sans se soucier de l'UI,
- au widget de récupérer un payload déjà "prêt à afficher".
4. Câbler le tool email_auth_audit côté MCP
Côté MCP, le handler email_auth_audit fait trois choses :
- Appeler le backend avec le domaine et récupérer un JSON d'audit du type :
{
"domain": "captaindns.com",
"score": 57,
"max_score": 100,
"percentage": 57,
"authentication_summary": "Authentification partielle : renforcez SPF/DKIM/DMARC.",
"checks": [ ... ],
"score_breakdown": { ... },
"findings": { ... },
"notes": [ ... ],
"ui_meta": { ... }
}
- Remapper vers
structuredContent
Ici, nous reprenons quasiment le JSON tel quel :
"structuredContent": {
"domain": "captaindns.com",
"ready_to_send": true,
"degraded": true,
"grade": "moyen",
"score": 57,
"max_score": 100,
"percentage": 57,
"authentication_score": 57,
"authentication_summary": "Authentification partielle : renforcez SPF/DKIM/DMARC.",
"checks": [ ... ],
"score_breakdown": { ... },
"findings": { ... },
"notes": [ ... ]
}
- Construire
_meta+content
"_meta": {
"emailAuthUi": {
"score": 57,
"summary": "Authentification partielle : renforcez SPF/DKIM/DMARC.",
"rating": "bad",
"checks": [ ... ]
},
"openai/outputTemplate": "ui://widget/email-auth-widget.html",
"openai/toolInvocation/invoking": "Analyse de l'authentification email en cours...",
"openai/toolInvocation/invoked": "Analyse d'authentification email terminée."
},
"content": [
{
"type": "text",
"text": "Score d'authentification: 57/100 — Authentification partielle : renforcez SPF/DKIM/DMARC."
}
]
La partie "raisonnement" de ChatGPT repose sur structuredContent et content. Le widget, lui, exploitera structuredContent et _meta.emailAuthUi.
5. Exposer le template HTML comme resource MCP
Ensuite, nous avons dû déclarer le template côté MCP.
5.1. resources/list : déclarer les resources
Le MCP expose trois resources pour ce widget :
ui://widget/email-auth-widget.html(HTML + CSS + JS),ui://widget/apps-sdk-bridge.js(bridge Apps SDK optionnel),- éventuellement d'autres assets.
resources/list renvoie un tableau de descripteurs avec uri, name, description, mimeType.
5.2. resources/read : le piège du invalid_union
La première version de resources/read renvoyait un JSON du type :
{
"contents": [
{
"uri": { "value": "ui://widget/email-auth-widget.html" }, // mauvais
"mimeType": "text/html+skybridge",
"text": "<!doctype html>..."
}
]
}
Résultat dans MCP Inspector : une belle erreur invalid_union sur contents[0].uri.
Le client MCP s'attend très exactement à :
{
"contents": [
{
"uri": "ui://widget/email-auth-widget.html",
"mimeType": "text/html+skybridge",
"text": "<!doctype html>..."
}
]
}
Une fois les types corrigés (uri et text en string), MCP Inspector a pu afficher le HTML, et ChatGPT a pu charger le template sans broncher.
6. Dans le widget : comprendre window.openai
Dernière étape – et non des moindres : l'implémentation du widget lui-même.
6.1. Ce que fournit ChatGPT au template
Dans un template Apps SDK, ChatGPT injecte un objet global window.openai dont les champs évoluent avec le temps :
- au chargement,
window.openai.toolOutputpeut êtrenullou vide ; - quand le tool est exécuté, ChatGPT émet un évènement
openai:set_globalset met à jour :globals.toolOutput,globals.toolResponseMetadata.
Au début, notre script supposait que window.openai.toolOutput contenait immédiatement un objet du type structuredContent. C'était faux, d'où un rendu avec :
Domaine inconnu,Score indisponible,Aucun check disponible.
6.2. Normaliser la forme de toolOutput
Nous avons commencé par écrire un helper qui accepte plusieurs formes possibles :
function extractStructured(toolOutputSource) {
if (!toolOutputSource) return {};
// Cas 1 : structuredContent direct
if (toolOutputSource.domain || toolOutputSource.score || toolOutputSource.checks) {
return toolOutputSource;
}
// Cas 2 : { structuredContent: {...} }
if (toolOutputSource.structuredContent) {
return toolOutputSource.structuredContent;
}
// Cas 3 : { result: { structuredContent: {...} } }
if (toolOutputSource.result?.structuredContent) {
return toolOutputSource.result.structuredContent;
}
return toolOutputSource;
}
Puis, dans normalize, au lieu de faire :
const structured =
toolOutputSource?.structuredContent ?? toolOutputSource ?? {};
on fait :
const structured = extractStructured(toolOutputSource);
6.3. Gérer le timing avec openai:set_globals
Ensuite, nous avons branché le widget sur l'évènement openai:set_globals :
function renderFromContext() {
const ctx = window.openai || {};
const toolOutput = ctx.toolOutput || ctx.toolInvocationResult || {};
const meta = ctx.toolResponseMetadata || {};
const data = normalize(toolOutput, meta);
render(data);
}
// Premier rendu optimiste
renderFromContext();
// Rendu réactif quand ChatGPT pousse les résultats du tool
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 }
);
Une fois ces deux points en place, le widget s'est mis à afficher :
captaindns.comen haut à gauche,57/100 (57%)en haut à droite,- le résumé, et les lignes MX/SPF/DKIM/DMARC/BIMI avec leurs statuts.
7. Résultat : le widget en action
La vue finale ressemble à une carte CaptainDNS rendue directement dans la conversation :
- Titre :
captaindns.com. - Score :
57/100 (57%), avec une couleur d'état (baddans notre cas). - Résumé : "Authentification partielle : renforcez SPF/DKIM/DMARC."
- Tableau :
- MX : OK –
1 smtp.google.com. - SPF : OK –
v=spf1 include:_spf.google.com -all - DKIM : Info – "Aucun enregistrement trouvé"
- DMARC : OK –
v=DMARC1; p=reject; rua=mailto:dmarc@captaindns.com; - BIMI : Avertissement – "Aucun enregistrement trouvé"
- MX : OK –

8. Points d'attention et checklist
Pour résumer, voici la checklist que nous utilisons désormais pour tout nouveau widget Apps SDK branché sur le MCP CaptainDNS :
- Descriptor du tool (
tools/list) :
_meta["openai/outputTemplate"]pointe vers une resource HTMLtext/html+skybridge._meta["openai/toolInvocation/invoking"]/invokedsont renseignés.
- Resource HTML (
resources/read) :
contents[0].uriest une string (exactement l'URI de la resource).contents[0].mimeType === "text/html+skybridge".contents[0].textcontient un HTML valide.
- Réponse de tool (
tools/call) :
structuredContentcontient les données métier._metacontient :- les métadonnées UI (
emailAuthUidans notre cas), - les clés
openai/*si l'on veut doubler la déclaration. contentcontient une phrase lisible dans la conversation.
- les métadonnées UI (
- Widget HTML :
- lit
window.openai.toolOutput/toolResponseMetadata, - normalise la forme de
toolOutput, - écoute
openai:set_globals, - ne laisse aucun debug polluer le rendu final.
Avec ces garde-fous, l'ajout de nouveaux widgets (par exemple pour l'audit DNS autoritatif ou la propagation) devient beaucoup plus fluide.
9. Et après ?
Ce premier widget email_auth_audit nous sert de référence pour les prochains composants UI CaptainDNS dans ChatGPT :
- un widget de santé DNS autoritative (
domain_dns_check), - un widget de propagation DNS multi-résolveurs,
- voire des dashboards plus complets, croisant les résultats de plusieurs tools.
Le point le plus important de ce REX est finalement simple :
le rendu Apps SDK n'est pas "magique" ; il repose sur un contrat très précis entre le descriptor du tool, la réponse du tool et les resources HTML.
Une fois ce contrat maîtrisé, on peut transformer des outils MCP assez bruts en expériences utilisateur beaucoup plus lisibles directement dans la conversation ChatGPT, sans dupliquer la logique métier côté frontend.
