Integrare un widget ChatGPT per l'audit di autenticazione email con CaptainDNS
Di CaptainDNS
Pubblicato il 7 dicembre 2025
- #MCP
- #ChatGPT
- #Apps SDK
- #DNS
- #Architettura

- Obiettivo: mostrare in ChatGPT un componente visivo di audit SPF/DKIM/DMARC/BIMI alimentato dal server MCP di CaptainDNS.
- In pratica bisogna allineare tre elementi: il descriptor del tool MCP, la risposta del tool (
structuredContent+_meta) e una risorsa HTMLtext/html+skybridge. - Principali difficoltà: decidere dove posizionare
openai/outputTemplate, correggere la forma diresources/reade gestire il timing diwindow.openai.toolOutputnel widget. - Risultato: un widget
email_auth_auditche rende punteggio, riepilogo e verifiche MX/SPF/DKIM/DMARC/BIMI direttamente nella conversazione ChatGPT senza perdere il reasoning dell'assistente.
1. Contesto: perché un widget ChatGPT per CaptainDNS?
CaptainDNS offre già un'API di audit dell'autenticazione email: calcoliamo un punteggio di readiness all'invio basato su MX, SPF, DKIM, DMARC e BIMI per un dominio.
Con il server MCP CaptainDNS potevamo già lanciare questo audit da MCP Inspector e poi da un'App ChatGPT. Sul piano UX mancava qualcosa:
- l'utente vedeva un blocco di testo,
- ma non una vista sintetica: punteggio, stato per meccanismo, riepilogo.
Il nuovo Apps SDK UI di OpenAI permette proprio di agganciare un template HTML a un tool per renderizzare un componente nella conversazione. L'idea era semplice:
Quando ChatGPT chiama
email_auth_audit, mostrare un widget CaptainDNS che riassume l'audit, e lasciare che l'assistente dettaglia le correzioni DNS.
La realtà è stata un po' più sottile.
Questo articolo racconta cosa si aspetta esattamente ChatGPT, gli scogli incontrati e come siamo arrivati al seguente rendering:
(vedi la sezione "Risultato: il widget in azione" per lo screenshot, da integrare come immagine).
2. Architettura complessiva: backend, MCP, Apps SDK
Dal lato CaptainDNS, l'architettura di base è:
- Frontend: Next.js su Vercel (
captaindns.com). - API backend: Go su Fly.io, che gestisce la logica (audit DNS, scoring, ecc.).
- Server MCP: Go su Fly.io anch'esso, parla JSON-RPC con MCP Inspector / ChatGPT e delega al backend.
Per il widget aggiungiamo un nuovo attore: l'App ChatGPT che consuma l'MCP CaptainDNS e sa renderizzare una risorsa HTML text/html+skybridge.
Flusso semplificato per email_auth_audit:
- L'utente chiede a ChatGPT: "Puoi auditare la posta di captaindns.com?"
- Il modello decide di chiamare il tool MCP
email_auth_audit. - Il server MCP chiama l'API backend, riceve il JSON di audit e lo rimappa in
structuredContent+_meta. - ChatGPT renderizza:
- un blocco di testo basato su
content+structuredContent, - un widget caricando
ui://widget/email-auth-widget.htmlreferenziato daopenai/outputTemplate.
- un blocco di testo basato su
Puoi visualizzare questo flusso con un diagramma del tipo:

3. Cosa si aspetta davvero ChatGPT da un tool MCP “widget”
La prima difficoltà è stata capire dove dichiarare cosa. Nel Apps SDK ci sono tre livelli distinti:
- Descriptor del tool (
tools/list)
- Qui si dichiarano i metadati OpenAI:
_meta["openai/outputTemplate"] = "ui://widget/email-auth-widget.html",_meta["openai/toolInvocation/invoking"]/invoked(i piccoli messaggi sopra il widget).
- In parallelo puoi mantenere
annotationsper esigenze proprie (tag, scope OAuth2, ecc.).
- Resource HTML (
resources/list+resources/read)
- URI:
ui://widget/email-auth-widget.html. mimeType: obbligatoriamentetext/html+skybridge.- Il contenuto è un HTML classico con uno
<script type="module">che leggewindow.openai.
- Risposta del tool (
tools/call)
structuredContent: i dati grezzi che il modello userà per ragionare._meta: i metadati specifici del risultato, visibili dal widget.content: una versione testuale "fallback" per la conversazione.
Per il widget email_auth_audit abbiamo adottato la convenzione seguente:
structuredContent= JSON completo dell'audit (dominio, punteggio, checks, breakdown, note...)._meta.emailAuthUi= la versione "pronta per la UI": punteggio, riepilogo, rating (good/ok/bad), checks normalizzati._meta["openai/outputTemplate"]+openai/toolInvocation/*= presenti sia nel:- descriptor del tool,
- sia nella risposta del tool (per sicurezza: alcune guide dell'Apps SDK raccomandano entrambi).
Questa separazione permette:
- al modello di leggere i dati senza preoccuparsi della UI,
- al widget di recuperare un payload già "pronto da rendere".
4. Cablaggio del tool email_auth_audit lato MCP
L'handler email_auth_audit sul MCP fa tre cose:
- Chiamare il backend con il dominio e recuperare un JSON di audit del tipo:
{
"domain": "captaindns.com",
"score": 57,
"max_score": 100,
"percentage": 57,
"authentication_summary": "Autenticazione parziale: rafforza SPF/DKIM/DMARC.",
"checks": [ ... ],
"score_breakdown": { ... },
"findings": { ... },
"notes": [ ... ],
"ui_meta": { ... }
}
- Rimappare verso
structuredContent
Qui riutilizziamo quasi il JSON così com'è:
"structuredContent": {
"domain": "captaindns.com",
"ready_to_send": true,
"degraded": true,
"grade": "medio",
"score": 57,
"max_score": 100,
"percentage": 57,
"authentication_score": 57,
"authentication_summary": "Autenticazione parziale: rafforza SPF/DKIM/DMARC.",
"checks": [ ... ],
"score_breakdown": { ... },
"findings": { ... },
"notes": [ ... ]
}
- Costruire
_meta+content
"_meta": {
"emailAuthUi": {
"score": 57,
"summary": "Autenticazione parziale: rafforza SPF/DKIM/DMARC.",
"rating": "bad",
"checks": [ ... ]
},
"openai/outputTemplate": "ui://widget/email-auth-widget.html",
"openai/toolInvocation/invoking": "Audit di autenticazione email in corso...",
"openai/toolInvocation/invoked": "Audit di autenticazione email completato."
},
"content": [
{
"type": "text",
"text": "Punteggio di autenticazione: 57/100 — Autenticazione parziale: rafforza SPF/DKIM/DMARC."
}
]
La parte di "reasoning" di ChatGPT si appoggia a structuredContent e content. Il widget usa structuredContent e _meta.emailAuthUi.
5. Esporre il template HTML come resource MCP
Poi abbiamo dovuto dichiarare il template lato MCP.
5.1. resources/list: dichiarare i resources
Il MCP espone tre resources per questo widget:
ui://widget/email-auth-widget.html(HTML + CSS + JS),ui://widget/apps-sdk-bridge.js(bridge Apps SDK opzionale),- eventuali altri asset.
resources/list restituisce un array di descrittori con uri, name, description, mimeType.
5.2. resources/read: la trappola invalid_union
La prima versione di resources/read restituiva un JSON di forma:
{
"contents": [
{
"uri": { "value": "ui://widget/email-auth-widget.html" }, // sbagliato
"mimeType": "text/html+skybridge",
"text": "<!doctype html>..."
}
]
}
Risultato in MCP Inspector: un bell'errore invalid_union su contents[0].uri.
Il client MCP si aspetta esattamente:
{
"contents": [
{
"uri": "ui://widget/email-auth-widget.html",
"mimeType": "text/html+skybridge",
"text": "<!doctype html>..."
}
]
}
Una volta corretti i tipi (uri e text come stringhe), MCP Inspector ha potuto mostrare l'HTML e ChatGPT ha caricato il template senza problemi.
6. Dentro il widget: capire window.openai
Ultimo step, e non banale: l'implementazione del widget.
6.1. Cosa fornisce ChatGPT al template
In un template Apps SDK, ChatGPT inietta un oggetto globale window.openai i cui campi evolvono nel tempo:
- al caricamento,
window.openai.toolOutputpuò esserenullo vuoto; - quando il tool viene eseguito, ChatGPT emette un evento
openai:set_globalse aggiorna:globals.toolOutput,globals.toolResponseMetadata.
All'inizio il nostro script supponeva che window.openai.toolOutput contenesse subito un oggetto di tipo structuredContent. Non era così, da cui un render con:
Dominio sconosciuto,Punteggio non disponibile,Nessun check disponibile.
6.2. Normalizzare la forma di toolOutput
Abbiamo iniziato scrivendo un helper che accetta diverse forme possibili:
function extractStructured(toolOutputSource) {
if (!toolOutputSource) return {};
// Caso 1: structuredContent diretto
if (toolOutputSource.domain || toolOutputSource.score || toolOutputSource.checks) {
return toolOutputSource;
}
// Caso 2: { structuredContent: {...} }
if (toolOutputSource.structuredContent) {
return toolOutputSource.structuredContent;
}
// Caso 3: { result: { structuredContent: {...} } }
if (toolOutputSource.result?.structuredContent) {
return toolOutputSource.result.structuredContent;
}
return toolOutputSource;
}
Poi, in normalize, invece di fare:
const structured =
toolOutputSource?.structuredContent ?? toolOutputSource ?? {};
facciamo:
const structured = extractStructured(toolOutputSource);
6.3. Gestire il timing con openai:set_globals
Abbiamo poi collegato il widget all'evento 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);
}
// Primo render ottimistico
renderFromContext();
// Render reattivo quando ChatGPT invia i risultati del 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 }
);
Con questi due punti in ordine, il widget ha iniziato a mostrare:
captaindns.comin alto a sinistra,57/100 (57%)in alto a destra,- il riepilogo, e le righe MX/SPF/DKIM/DMARC/BIMI con i rispettivi stati.
7. Risultato: il widget in azione
La vista finale sembra una card CaptainDNS renderizzata direttamente nella conversazione:
- Titolo:
captaindns.com. - Punteggio:
57/100 (57%), con un colore di stato (badnel nostro caso). - Riepilogo: "Autenticazione parziale: rafforza SPF/DKIM/DMARC."
- Tabella:
- MX: OK –
1 smtp.google.com. - SPF: OK –
v=spf1 include:_spf.google.com -all - DKIM: Info – "Nessun record trovato"
- DMARC: OK –
v=DMARC1; p=reject; rua=mailto:dmarc@captaindns.com; - BIMI: Avviso – "Nessun record trovato"
- MX: OK –

8. Punti di attenzione e checklist
In sintesi, ecco la checklist che usiamo ora per ogni nuovo widget Apps SDK collegato all'MCP di CaptainDNS:
- Descriptor del tool (
tools/list):
_meta["openai/outputTemplate"]punta a una risorsa HTMLtext/html+skybridge._meta["openai/toolInvocation/invoking"]/invokedsono valorizzati.
- Resource HTML (
resources/read):
contents[0].uriè una stringa (esattamente l'URI della risorsa).contents[0].mimeType === "text/html+skybridge".contents[0].textcontiene HTML valido.
- Risposta del tool (
tools/call):
structuredContentcontiene i dati di business._metacontiene:- i metadati UI (
emailAuthUiqui), - le chiavi
openai/*se si vuole duplicare la dichiarazione. contentcontiene una frase leggibile per la conversazione.
- i metadati UI (
- Widget HTML:
- legge
window.openai.toolOutput/toolResponseMetadata, - normalizza la forma di
toolOutput, - ascolta
openai:set_globals, - non lascia debug nel render finale.
Con queste salvaguardie, aggiungere nuovi widget (ad esempio per la salute DNS autoritativa o la propagazione) diventa molto più fluido.
9. E dopo?
Questo primo widget email_auth_audit ci serve da riferimento per i prossimi componenti UI CaptainDNS in ChatGPT:
- un widget di salute DNS autoritativa (
domain_dns_check), - un widget di propagazione DNS multi-resolver,
- o persino dashboard più ricchi che incrociano i risultati di più tool.
Il punto più importante di questo REX è in realtà semplice:
il rendering Apps SDK non è "magico"; si basa su un contratto molto preciso tra descriptor del tool, risposta del tool e risorse HTML.
Una volta padroneggiato quel contratto, puoi trasformare tool MCP piuttosto grezzi in esperienze utente molto più leggibili direttamente nella conversazione ChatGPT, senza duplicare la logica di business nel frontend.
