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
  • #Email
  • #Arquitectura
Diagrama que muestra a ChatGPT llamando al servidor MCP CaptainDNS y renderizando un widget de auditoría de autenticación de email
TL;DR
  • 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 HTML text/html+skybridge.
  • Dificultades principales: decidir dónde poner openai/outputTemplate, corregir la forma de resources/read y gestionar el timing de window.openai.toolOutput en el widget.
  • Resultado: un widget email_auth_audit que 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:

  1. El usuario pide a ChatGPT: "¿Puedes auditar la mensajería de captaindns.com?"
  2. El modelo decide llamar al tool MCP email_auth_audit.
  3. El servidor MCP llama a la API backend, recibe el JSON de auditoría y lo re-mapea en structuredContent + _meta.
  4. ChatGPT renderiza:
    • un bloque de texto basado en content + structuredContent,
    • un widget cargando ui://widget/email-auth-widget.html referenciado por openai/outputTemplate.

Puedes visualizar este flujo con un diagrama así:

Flujo MCP + Apps SDK para el widget email_auth_audit

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:

  1. 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 annotations para tus necesidades (tags, scopes OAuth2, etc.).
  1. Resource HTML (resources/list + resources/read)
  • URI: ui://widget/email-auth-widget.html.
  • mimeType: obligatoriamente text/html+skybridge.
  • El contenido es un HTML clásico, con un <script type="module"> que lee window.openai.
  1. 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:

  1. 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": { ... }
}
  1. 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": [ ... ]
}
  1. 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.toolOutput puede ser null o estar vacío;
  • cuando se ejecuta el tool, ChatGPT emite un evento openai:set_globals y 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.com arriba 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 (bad en 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"

Captura del widget email_auth_audit en ChatGPT mostrando la nota y los checks MX/SPF/DKIM/DMARC/BIMI para captaindns.com

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:

  1. Descriptor del tool (tools/list):
  • _meta["openai/outputTemplate"] apunta a un recurso HTML text/html+skybridge.
  • _meta["openai/toolInvocation/invoking"] / invoked están rellenos.
  1. Resource HTML (resources/read):
  • contents[0].uri es un string (exactamente la URI del resource).
  • contents[0].mimeType === "text/html+skybridge".
  • contents[0].text contiene HTML válido.
  1. Respuesta del tool (tools/call):
  • structuredContent contiene los datos de negocio.
  • _meta contiene:
    • los metadatos UI (emailAuthUi aquí),
    • las claves openai/* si se quiere duplicar la declaración.
    • content contiene una frase legible para la conversación.
  1. 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.

Artículos relacionados

CaptainDNS · 4 de diciembre de 2025

Diagrama de arquitectura con Auth0, el servidor MCP CaptainDNS, la API backend y los clientes MCP

Auth0 + MCP CaptainDNS: nuestro REX completo

Cómo conectamos Auth0 a nuestro servidor MCP CaptainDNS: audiences dedicadas, PRM, Resource Parameter Compatibility Profile, validación JWT, propagación de identidad hasta profiles y api_requests, con auth opcional hoy y herramientas protegidas listas para después.

  • #Auth0
  • #MCP
  • #OAuth2
  • #DNS
  • #Arquitectura

CaptainDNS · 27 de noviembre de 2025

Diagrama de la arquitectura MCP de CaptainDNS entre ChatGPT, el servidor MCP y la API backend

Entre bastidores del MCP de CaptainDNS

Cómo conectamos CaptainDNS a las IA mediante MCP: arquitectura, transporte HTTP+SSE, JSON-RPC, errores 424 y timeouts, y lo que aprendimos por el camino.

  • #MCP
  • #Arquitectura
  • #DNS
  • #Correo
  • #Integraciones IA

CaptainDNS · 21 de noviembre de 2025

Esquema de un host de IA que habla con CaptainDNS a través de un conector MCP estandarizado

¿Un MCP para CaptainDNS?

Antes de conectar CaptainDNS a las IAs, hay que entender qué es el Model Context Protocol (MCP) y lo que permite de verdad. Un ABC del MCP y las primeras pistas para CaptainDNS.

  • #MCP
  • #IA
  • #DNS
  • #Correo
  • #Arquitectura