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
- #Arquitetura

- 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 HTMLtext/html+skybridge. - Principais dificuldades: decidir onde colocar
openai/outputTemplate, corrigir o formato deresources/reade gerenciar o timing dewindow.openai.toolOutputno widget. - Resultado: um widget
email_auth_auditque 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:
- O usuário pede ao ChatGPT: "Pode auditar o correio de captaindns.com?"
- O modelo decide chamar o tool MCP
email_auth_audit. - O servidor MCP chama a API backend, recebe o JSON do audit e faz o remap para
structuredContent+_meta. - O ChatGPT renderiza:
- um bloco de texto baseado em
content+structuredContent, - um widget carregando
ui://widget/email-auth-widget.htmlreferenciado poropenai/outputTemplate.
- um bloco de texto baseado em
Você pode visualizar esse fluxo com um diagrama assim:

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:
- 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
annotationsem paralelo para nossas necessidades (tags, escopos OAuth2, etc.).
- Recurso HTML (
resources/list+resources/read)
- URI:
ui://widget/email-auth-widget.html. mimeType: obrigatoriamentetext/html+skybridge.- O conteúdo é um HTML clássico, com um
<script type="module">que lêwindow.openai.
- 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:
- 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": { ... }
}
- 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": [ ... ]
}
- 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.toolOutputpode sernullou vazio; - quando o tool é executado, o ChatGPT emite um evento
openai:set_globalse 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.comno 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 (badno 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"
- MX: OK –

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:
- Descriptor do tool (
tools/list):
_meta["openai/outputTemplate"]aponta para um recurso HTMLtext/html+skybridge._meta["openai/toolInvocation/invoking"]/invokedestão preenchidos.
- Recurso HTML (
resources/read):
contents[0].urié uma string (exatamente a URI do recurso).contents[0].mimeType === "text/html+skybridge".contents[0].textcontém HTML válido.
- Resposta do tool (
tools/call):
structuredContenttraz os dados de negócio._metacontém:- os metadados de UI (
emailAuthUiaqui), - as chaves
openai/*se quiser duplicar a declaração. contentcontém uma frase legível para a conversa.
- os metadados de UI (
- Widget HTML:
- lê
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.
