Integrar um widget ChatGPT para o audit de autenticação de e-mail com a CaptainDNS

Por CaptainDNS
Publicado em 7 de dezembro de 2025

  • #MCP
  • #ChatGPT
  • #Apps SDK
  • #DNS
  • #E-mail
  • #Arquitetura
Diagrama mostrando o ChatGPT chamando o servidor MCP CaptainDNS e renderizando um widget de auditoria de autenticação de e-mail
TL;DR
  • Objetivo: exibir no ChatGPT um componente visual de auditoria SPF/DKIM/DMARC/BIMI, alimentado pelo servidor MCP da CaptainDNS.
  • Na prática é preciso alinhar três pontos: o descriptor do tool MCP, a resposta do tool (structuredContent + _meta) e um recurso HTML text/html+skybridge.
  • Principais dificuldades: decidir onde colocar openai/outputTemplate, corrigir o formato de resources/read e gerenciar o timing de window.openai.toolOutput no widget.
  • Resultado: um widget email_auth_audit que renderiza a nota, o resumo e os checks MX/SPF/DKIM/DMARC/BIMI diretamente na conversa do ChatGPT sem perder o raciocínio do assistente.

1. Contexto: por que um widget ChatGPT para a CaptainDNS?

A CaptainDNS já oferece uma API de auditoria de autenticação de e-mail: calculamos uma pontuação de prontidão de envio a partir de MX, SPF, DKIM, DMARC e BIMI para um domínio.

Com o servidor MCP da CaptainDNS já podíamos disparar esse audit pelo MCP Inspector e depois por um App ChatGPT. No UX ainda faltava algo:

  • o usuário via um bloco de texto,
  • mas não uma visão sintética: nota, status por mecanismo, resumo.

O novo Apps SDK UI da OpenAI permite anexar um template HTML a um tool para renderizar um componente na conversa. A ideia era simples:

Quando o ChatGPT chama email_auth_audit, exibir um widget CaptainDNS que resume o audit e deixar o assistente detalhar as correções de DNS.

Na prática foi um pouco mais sutil.

Este artigo conta o que o ChatGPT espera exatamente, as armadilhas encontradas e como chegamos ao seguinte render:

(veja a seção "Resultado: o widget em ação" para a captura, a integrar como imagem).

2. Arquitetura geral: backend, MCP, Apps SDK

Do lado da CaptainDNS a arquitetura base é:

  • Frontend: Next.js na Vercel (captaindns.com).
  • API backend: Go na Fly.io, que cuida da lógica (audit DNS, scoring etc.).
  • Servidor MCP: Go na Fly.io também, que fala JSON-RPC com MCP Inspector / ChatGPT e delega ao backend.

Para o widget adicionamos um novo ator: o App ChatGPT, que consome o MCP da CaptainDNS e sabe renderizar um recurso HTML text/html+skybridge.

Fluxo simplificado para email_auth_audit:

  1. O usuário pede ao ChatGPT: "Pode auditar o correio de captaindns.com?"
  2. O modelo decide chamar o tool MCP email_auth_audit.
  3. O servidor MCP chama a API backend, recebe o JSON do audit e faz o remap para structuredContent + _meta.
  4. O ChatGPT renderiza:
    • um bloco de texto baseado em content + structuredContent,
    • um widget carregando ui://widget/email-auth-widget.html referenciado por openai/outputTemplate.

Você pode visualizar esse fluxo com um diagrama assim:

Fluxo MCP + Apps SDK para o widget email_auth_audit

3. O que o ChatGPT realmente espera de um tool MCP “widget”

A primeira dificuldade foi entender onde declarar cada coisa. No Apps SDK existem três camadas distintas:

  1. Descriptor do tool (tools/list)
  • Aqui declaramos os metadados OpenAI:
    • _meta["openai/outputTemplate"] = "ui://widget/email-auth-widget.html",
    • _meta["openai/toolInvocation/invoking"] / invoked (as pequenas mensagens acima do widget).
  • Podemos manter annotations em paralelo para nossas necessidades (tags, escopos OAuth2, etc.).
  1. Recurso HTML (resources/list + resources/read)
  • URI: ui://widget/email-auth-widget.html.
  • mimeType: obrigatoriamente text/html+skybridge.
  • O conteúdo é um HTML clássico, com um <script type="module"> que lê window.openai.
  1. Resposta do tool (tools/call)
  • structuredContent: os dados brutos que o modelo usará para raciocinar.
  • _meta: os metadados específicos do resultado, visíveis pelo widget.
  • content: uma versão textual "fallback" para a conversa.

Para o widget email_auth_audit adotamos a convenção abaixo:

  • structuredContent = JSON completo do audit (domínio, nota, checks, breakdown, notas...).
  • _meta.emailAuthUi = a versão "pronta para UI": nota, resumo, rating (good/ok/bad), checks normalizados.
  • _meta["openai/outputTemplate"] + openai/toolInvocation/* = presentes tanto no:
    • descriptor do tool,
    • quanto na resposta do tool (por segurança: alguns guias do Apps SDK recomendam ambos).

Essa separação permite:

  • que o modelo leia os dados sem se preocupar com a UI,
  • que o widget recupere um payload já "pronto para renderizar".

4. Cabeamento do tool email_auth_audit no MCP

No MCP, o handler email_auth_audit faz três coisas:

  1. Chamar o backend com o domínio e recuperar um JSON de audit como:
{
  "domain": "captaindns.com",
  "score": 57,
  "max_score": 100,
  "percentage": 57,
  "authentication_summary": "Autenticação parcial: reforce SPF/DKIM/DMARC.",
  "checks": [ ... ],
  "score_breakdown": { ... },
  "findings": { ... },
  "notes": [ ... ],
  "ui_meta": { ... }
}
  1. Remapear para structuredContent

Aqui reutilizamos praticamente o JSON como está:

"structuredContent": {
  "domain": "captaindns.com",
  "ready_to_send": true,
  "degraded": true,
  "grade": "médio",
  "score": 57,
  "max_score": 100,
  "percentage": 57,
  "authentication_score": 57,
  "authentication_summary": "Autenticação parcial: reforce SPF/DKIM/DMARC.",
  "checks": [ ... ],
  "score_breakdown": { ... },
  "findings": { ... },
  "notes": [ ... ]
}
  1. Montar _meta + content
"_meta": {
  "emailAuthUi": {
    "score": 57,
    "summary": "Autenticação parcial: reforce SPF/DKIM/DMARC.",
    "rating": "bad",
    "checks": [ ... ]
  },
  "openai/outputTemplate": "ui://widget/email-auth-widget.html",
  "openai/toolInvocation/invoking": "Audit de autenticação de e-mail em andamento...",
  "openai/toolInvocation/invoked": "Audit de autenticação de e-mail concluído."
},
"content": [
  {
    "type": "text",
    "text": "Nota de autenticação: 57/100 — Autenticação parcial: reforce SPF/DKIM/DMARC."
  }
]

A parte de "raciocínio" do ChatGPT se apoia em structuredContent e content. O widget usa structuredContent e _meta.emailAuthUi.

5. Expor o template HTML como resource MCP

Em seguida foi preciso declarar o template do lado MCP.

5.1. resources/list: declarar os resources

O MCP expõe três resources para este widget:

  • ui://widget/email-auth-widget.html (HTML + CSS + JS),
  • ui://widget/apps-sdk-bridge.js (bridge Apps SDK opcional),
  • outros assets se necessário.

resources/list retorna um array de descritores com uri, name, description, mimeType.

5.2. resources/read: a armadilha invalid_union

A primeira versão de resources/read devolvia um JSON deste tipo:

{
  "contents": [
    {
      "uri": { "value": "ui://widget/email-auth-widget.html" }, // errado
      "mimeType": "text/html+skybridge",
      "text": "<!doctype html>..."
    }
  ]
}

Resultado no MCP Inspector: um belo erro invalid_union em contents[0].uri. O cliente MCP espera exatamente:

{
  "contents": [
    {
      "uri": "ui://widget/email-auth-widget.html",
      "mimeType": "text/html+skybridge",
      "text": "<!doctype html>..."
    }
  ]
}

Depois de corrigir os tipos (uri e text como strings), o MCP Inspector pôde exibir o HTML e o ChatGPT carregou o template sem reclamar.

6. Dentro do widget: entender window.openai

Última etapa – e não pequena: a implementação do widget.

6.1. O que o ChatGPT entrega ao template

Num template Apps SDK, o ChatGPT injeta um objeto global window.openai cujos campos evoluem com o tempo:

  • ao carregar, window.openai.toolOutput pode ser null ou vazio;
  • quando o tool é executado, o ChatGPT emite um evento openai:set_globals e atualiza:
    • globals.toolOutput,
    • globals.toolResponseMetadata.

No início, nosso script supunha que window.openai.toolOutput continha imediatamente um objeto do tipo structuredContent. Era falso, daí um render com:

  • Domínio desconhecido,
  • Nota indisponível,
  • Nenhum check disponível.

6.2. Normalizar a forma de toolOutput

Começamos escrevendo um helper que aceita várias formas possíveis:

function extractStructured(toolOutputSource) {
  if (!toolOutputSource) return {};
  // Caso 1: structuredContent direto
  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;
}

Depois, em normalize, em vez de:

const structured =
  toolOutputSource?.structuredContent ?? toolOutputSource ?? {};

fazemos:

const structured = extractStructured(toolOutputSource);

6.3. Lidar com o timing usando openai:set_globals

Em seguida conectamos o widget ao 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);
}

// Primeiro render otimista
renderFromContext();

// Render reativo quando o ChatGPT envia os resultados do 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 }
);

Com esses dois pontos, o widget passou a mostrar:

  • captaindns.com no canto superior esquerdo,
  • 57/100 (57%) no canto superior direito,
  • o resumo e as linhas MX/SPF/DKIM/DMARC/BIMI com seus status.

7. Resultado: o widget em ação

A visão final lembra um card da CaptainDNS renderizado diretamente na conversa:

  • Título: captaindns.com.
  • Nota: 57/100 (57%), com uma cor de status (bad no nosso caso).
  • Resumo: "Autenticação parcial: reforce SPF/DKIM/DMARC."
  • Tabela:
    • MX: OK – 1 smtp.google.com.
    • SPF: OK – v=spf1 include:_spf.google.com -all
    • DKIM: Info – "Nenhum registro encontrado"
    • DMARC: OK – v=DMARC1; p=reject; rua=mailto:dmarc@captaindns.com;
    • BIMI: Aviso – "Nenhum registro encontrado"

Captura do widget email_auth_audit no ChatGPT mostrando a nota e os checks MX/SPF/DKIM/DMARC/BIMI para captaindns.com

8. Pontos de atenção e checklist

Para recapitular, esta é a checklist que usamos agora para cada novo widget Apps SDK ligado ao MCP da CaptainDNS:

  1. Descriptor do tool (tools/list):
  • _meta["openai/outputTemplate"] aponta para um recurso HTML text/html+skybridge.
  • _meta["openai/toolInvocation/invoking"] / invoked estão preenchidos.
  1. Recurso HTML (resources/read):
  • contents[0].uri é uma string (exatamente a URI do recurso).
  • contents[0].mimeType === "text/html+skybridge".
  • contents[0].text contém HTML válido.
  1. Resposta do tool (tools/call):
  • structuredContent traz os dados de negócio.
  • _meta contém:
    • os metadados de UI (emailAuthUi aqui),
    • as chaves openai/* se quiser duplicar a declaração.
    • content contém uma frase legível para a conversa.
  1. Widget HTML:
  • window.openai.toolOutput / toolResponseMetadata,
  • normaliza a forma de toolOutput,
  • escuta openai:set_globals,
  • não deixa ruído de debug no render final.

Com essas salvaguardas, adicionar novos widgets (por exemplo para saúde DNS autoritativa ou propagação) fica bem mais fluido.

9. E depois?

Este primeiro widget email_auth_audit serve de referência para os próximos componentes de UI da CaptainDNS no ChatGPT:

  • um widget de saúde DNS autoritativa (domain_dns_check),
  • um widget de propagação DNS multi-resolvedor,
  • e até dashboards mais ricos que cruzem resultados de vários tools.

O ponto mais importante deste REX é na verdade simples:

o render do Apps SDK não é "mágico"; ele se apoia num contrato muito preciso entre o descriptor do tool, a resposta do tool e os recursos HTML.

Depois que você domina esse contrato, dá para transformar tools MCP bem brutos em experiências de usuário muito mais legíveis diretamente na conversa do ChatGPT, sem duplicar a lógica de negócio no frontend.

Artigos relacionados

CaptainDNS · 27 de novembro de 2025

Diagrama da arquitetura MCP do CaptainDNS entre o ChatGPT, o servidor MCP e a API backend

Nos bastidores do MCP do CaptainDNS

Como conectamos o CaptainDNS a IAs via MCP: arquitetura, transporte HTTP+SSE, JSON-RPC, erros 424 e timeouts, e o que aprendemos no caminho.

  • #MCP
  • #Arquitetura
  • #DNS
  • #E-mail
  • #Integrações IA

CaptainDNS · 21 de novembro de 2025

Esquema de um host de IA conversando com o CaptainDNS por um conector MCP padronizado

Um MCP para o CaptainDNS?

Antes de ligar o CaptainDNS a IAs, é preciso entender o que é o Model Context Protocol (MCP) e o que ele realmente permite. Um ABC do MCP e os primeiros passos para o CaptainDNS.

  • #MCP
  • #IA
  • #DNS
  • #E-mail
  • #Arquitetura

CaptainDNS · 4 de dezembro de 2025

Diagrama de arquitetura com Auth0, o servidor MCP CaptainDNS, a API backend e os clientes MCP

Auth0 + MCP CaptainDNS: nosso REX completo

Como conectamos o Auth0 ao servidor MCP do CaptainDNS: audiences dedicadas, PRM, Resource Parameter Compatibility Profile, validação JWT, propagação de identidade para profiles e api_requests, com auth opcional hoje e ferramentas protegidas prontas para depois.

  • #Auth0
  • #MCP
  • #OAuth2
  • #DNS
  • #Arquitetura