Integrar un widget ChatGPT para la auditoría de autenticación de correo con CaptainDNS
Por CaptainDNS
Publicado el 7 de diciembre de 2025
- #MCP
- #ChatGPT
- #Apps SDK
- #DNS
- #Arquitectura

- Objetivo: mostrar un componente visual de auditoría SPF/DKIM/DMARC/BIMI en ChatGPT, alimentado por el servidor MCP de CaptainDNS.
- En la práctica hay que alinear tres cosas: el descriptor del tool MCP, la respuesta del tool (
structuredContent+_meta) y un recurso HTMLtext/html+skybridge. - Dificultades principales: decidir dónde poner
openai/outputTemplate, corregir la forma deresources/ready gestionar el timing dewindow.openai.toolOutputen el widget. - Resultado: un widget
email_auth_auditque renderiza la nota, el resumen y los checks MX/SPF/DKIM/DMARC/BIMI directamente en la conversación de ChatGPT sin perder el razonamiento del asistente.
1. Contexto: ¿por qué un widget ChatGPT para CaptainDNS?
CaptainDNS ya ofrece una API de auditoría de autenticación de correo: calculamos una puntuación de preparación al envío a partir de MX, SPF, DKIM, DMARC y BIMI para un dominio dado.
Con el servidor MCP CaptainDNS ya podíamos lanzar esta auditoría desde MCP Inspector y luego desde una App ChatGPT. A nivel UX faltaba algo:
- el usuario veía un bloque de texto,
- pero no una vista sintética: nota, estado por mecanismo, resumen.
El nuevo Apps SDK UI de OpenAI permite adjuntar un template HTML a un tool para renderizar un componente en la conversación. La idea era sencilla:
Cuando ChatGPT llama a
email_auth_audit, mostrar un widget CaptainDNS que resuma la auditoría y dejar que el asistente detalle las correcciones DNS.
La realidad fue un poco más sutil.
Este artículo cuenta lo que ChatGPT espera exactamente, las trampas encontradas y cómo terminamos con el siguiente render:
(ver la sección "Resultado: el widget en acción" para la captura, a integrar como imagen).
2. Arquitectura global: backend, MCP, Apps SDK
En CaptainDNS la arquitectura base es:
- Frontend: Next.js en Vercel (
captaindns.com). - API backend: Go en Fly.io que gestiona la lógica (auditoría DNS, scoring, etc.).
- Servidor MCP: Go en Fly.io también, que habla JSON-RPC con MCP Inspector / ChatGPT y delega al backend.
Para el widget añadimos un nuevo actor: la App ChatGPT que consume el MCP CaptainDNS y sabe renderizar un recurso HTML text/html+skybridge.
Flujo simplificado para email_auth_audit:
- El usuario pide a ChatGPT: "¿Puedes auditar la mensajería de captaindns.com?"
- El modelo decide llamar al tool MCP
email_auth_audit. - El servidor MCP llama a la API backend, recibe el JSON de auditoría y lo re-mapea en
structuredContent+_meta. - ChatGPT renderiza:
- un bloque de texto basado en
content+structuredContent, - un widget cargando
ui://widget/email-auth-widget.htmlreferenciado poropenai/outputTemplate.
- un bloque de texto basado en
Puedes visualizar este flujo con un diagrama así:

3. Lo que ChatGPT espera realmente de un tool MCP “widget”
El primer reto fue entender dónde declarar cada cosa. En el Apps SDK hay tres capas distintas:
- Descriptor del tool (
tools/list)
- Ahí se declaran los metadatos OpenAI:
_meta["openai/outputTemplate"] = "ui://widget/email-auth-widget.html",_meta["openai/toolInvocation/invoking"]/invoked(los pequeños mensajes encima del widget).
- Puedes mantener en paralelo
annotationspara tus necesidades (tags, scopes OAuth2, etc.).
- Resource HTML (
resources/list+resources/read)
- URI:
ui://widget/email-auth-widget.html. mimeType: obligatoriamentetext/html+skybridge.- El contenido es un HTML clásico, con un
<script type="module">que leewindow.openai.
- Respuesta del tool (
tools/call)
structuredContent: los datos brutos que usará el modelo para razonar._meta: los metadatos específicos del resultado, visibles desde el widget.content: una versión texto "fallback" para la conversación.
Para el widget email_auth_audit adoptamos la siguiente convención:
structuredContent= JSON completo de la auditoría (dominio, nota, checks, breakdown, notas...)._meta.emailAuthUi= la versión "lista para UI": nota, resumen, rating (good/ok/bad), checks normalizados._meta["openai/outputTemplate"]+openai/toolInvocation/*= presentes a la vez en:- el descriptor del tool,
- y la respuesta del tool (por seguridad: algunas guías del Apps SDK recomiendan ambos).
Esta separación permite:
- que el modelo lea los datos sin preocuparse por la UI,
- que el widget recupere un payload ya "listo para mostrar".
4. Cablear el tool email_auth_audit en el MCP
En el MCP, el handler email_auth_audit hace tres cosas:
- Llamar al backend con el dominio y recuperar un JSON de auditoría del tipo:
{
"domain": "captaindns.com",
"score": 57,
"max_score": 100,
"percentage": 57,
"authentication_summary": "Autenticación parcial: refuerza SPF/DKIM/DMARC.",
"checks": [ ... ],
"score_breakdown": { ... },
"findings": { ... },
"notes": [ ... ],
"ui_meta": { ... }
}
- Remapear hacia
structuredContent
Aquí reutilizamos casi el JSON tal cual:
"structuredContent": {
"domain": "captaindns.com",
"ready_to_send": true,
"degraded": true,
"grade": "medio",
"score": 57,
"max_score": 100,
"percentage": 57,
"authentication_score": 57,
"authentication_summary": "Autenticación parcial: refuerza SPF/DKIM/DMARC.",
"checks": [ ... ],
"score_breakdown": { ... },
"findings": { ... },
"notes": [ ... ]
}
- Construir
_meta+content
"_meta": {
"emailAuthUi": {
"score": 57,
"summary": "Autenticación parcial: refuerza SPF/DKIM/DMARC.",
"rating": "bad",
"checks": [ ... ]
},
"openai/outputTemplate": "ui://widget/email-auth-widget.html",
"openai/toolInvocation/invoking": "Auditoría de autenticación de correo en curso...",
"openai/toolInvocation/invoked": "Auditoría de autenticación de correo completada."
},
"content": [
{
"type": "text",
"text": "Nota de autenticación: 57/100 — Autenticación parcial: refuerza SPF/DKIM/DMARC."
}
]
La parte "razonamiento" de ChatGPT se apoya en structuredContent y content. El widget usa structuredContent y _meta.emailAuthUi.
5. Exponer el template HTML como resource MCP
Después hubo que declarar el template del lado MCP.
5.1. resources/list: declarar los resources
El MCP expone tres resources para este widget:
ui://widget/email-auth-widget.html(HTML + CSS + JS),ui://widget/apps-sdk-bridge.js(bridge Apps SDK opcional),- otros assets.
resources/list devuelve un array de descriptores con uri, name, description, mimeType.
5.2. resources/read: la trampa invalid_union
La primera versión de resources/read devolvía un JSON de la forma:
{
"contents": [
{
"uri": { "value": "ui://widget/email-auth-widget.html" }, // incorrecto
"mimeType": "text/html+skybridge",
"text": "<!doctype html>..."
}
]
}
Resultado en MCP Inspector: un bonito error invalid_union en contents[0].uri.
El cliente MCP espera exactamente:
{
"contents": [
{
"uri": "ui://widget/email-auth-widget.html",
"mimeType": "text/html+skybridge",
"text": "<!doctype html>..."
}
]
}
Una vez corregidos los tipos (uri y text como strings), MCP Inspector pudo mostrar el HTML y ChatGPT pudo cargar el template sin rechistar.
6. Dentro del widget: entender window.openai
Último paso, y no menor: la implementación del widget.
6.1. Lo que ChatGPT aporta al template
En un template Apps SDK, ChatGPT inyecta un objeto global window.openai cuyos campos evolucionan con el tiempo:
- al cargar,
window.openai.toolOutputpuede sernullo estar vacío; - cuando se ejecuta el tool, ChatGPT emite un evento
openai:set_globalsy actualiza:globals.toolOutput,globals.toolResponseMetadata.
Al principio, nuestro script asumía que window.openai.toolOutput contenía inmediatamente un objeto tipo structuredContent. Era falso, de ahí un render con:
Dominio desconocido,Nota no disponible,Ningún check disponible.
6.2. Normalizar la forma de toolOutput
Empezamos escribiendo un helper que acepta varias formas posibles:
function extractStructured(toolOutputSource) {
if (!toolOutputSource) return {};
// Caso 1: structuredContent directo
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;
}
Luego, en normalize, en lugar de hacer:
const structured =
toolOutputSource?.structuredContent ?? toolOutputSource ?? {};
hacemos:
const structured = extractStructured(toolOutputSource);
6.3. Gestionar el timing con openai:set_globals
Después conectamos el widget al 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);
}
// Primer render optimista
renderFromContext();
// Render reactivo cuando ChatGPT envía los resultados 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 estos dos puntos, el widget empezó a mostrar:
captaindns.comarriba a la izquierda,57/100 (57%)arriba a la derecha,- el resumen, y las filas MX/SPF/DKIM/DMARC/BIMI con sus estados.
7. Resultado: el widget en acción
La vista final parece una tarjeta CaptainDNS renderizada directamente en la conversación:
- Título:
captaindns.com. - Nota:
57/100 (57%), con un color de estado (baden nuestro caso). - Resumen: "Autenticación parcial: refuerza SPF/DKIM/DMARC."
- Tabla:
- MX: OK –
1 smtp.google.com. - SPF: OK –
v=spf1 include:_spf.google.com -all - DKIM: Info – "Ningún registro encontrado"
- DMARC: OK –
v=DMARC1; p=reject; rua=mailto:dmarc@captaindns.com; - BIMI: Advertencia – "Ningún registro encontrado"
- MX: OK –

8. Puntos de atención y checklist
Para resumir, esta es la checklist que usamos ahora para cada nuevo widget Apps SDK conectado al MCP de CaptainDNS:
- Descriptor del tool (
tools/list):
_meta["openai/outputTemplate"]apunta a un recurso HTMLtext/html+skybridge._meta["openai/toolInvocation/invoking"]/invokedestán rellenos.
- Resource HTML (
resources/read):
contents[0].uries un string (exactamente la URI del resource).contents[0].mimeType === "text/html+skybridge".contents[0].textcontiene HTML válido.
- Respuesta del tool (
tools/call):
structuredContentcontiene los datos de negocio._metacontiene:- los metadatos UI (
emailAuthUiaquí), - las claves
openai/*si se quiere duplicar la declaración. contentcontiene una frase legible para la conversación.
- los metadatos UI (
- Widget HTML:
- lee
window.openai.toolOutput/toolResponseMetadata, - normaliza la forma de
toolOutput, - escucha
openai:set_globals, - no deja ningún debug en el render final.
Con estas salvaguardas, añadir nuevos widgets (por ejemplo para la salud DNS autoritativa o la propagación) se vuelve mucho más fluido.
9. ¿Y ahora?
Este primer widget email_auth_audit nos sirve de referencia para los próximos componentes UI de CaptainDNS en ChatGPT:
- un widget de salud DNS autoritativa (
domain_dns_check), - un widget de propagación DNS multi-resolvedor,
- e incluso dashboards más completos que crucen resultados de varios tools.
El punto más importante de este REX es en realidad sencillo:
el render Apps SDK no es "mágico"; se apoya en un contrato muy preciso entre el descriptor del tool, la respuesta del tool y los resources HTML.
Una vez dominas ese contrato, puedes transformar tools MCP bastante brutos en experiencias de usuario mucho más legibles directamente en la conversación de ChatGPT, sin duplicar la lógica de negocio en el frontend.
