Introdução
Os plugins usam o Model Context Protocol (MCP) para disponibilizar ao ChatGPT e ao Codex funcionalidades de um servidor. Neste tutorial, você vai usar os seguintes componentes:
- Um servidor MCP que define ferramentas e as disponibiliza ao ChatGPT e ao Codex.
- Um componente web opcional que o ChatGPT exibe em um iframe.
O ChatGPT implementa o padrão aberto de interface do MCP Apps. Assim, você pode criar a interface uma única vez e executá-la em diferentes hosts compatíveis com MCP Apps.
Neste guia, vamos criar um fluxo simples para gerenciar tarefas. A interface ficará em um único arquivo HTML que reúne a marcação, o CSS e o JavaScript.
Para ver exemplos mais avançados com React, consulte o repositório de exemplos no GitHub.
Crie um componente web
Esta etapa é opcional. Se você só precisa das ferramentas e dispensa a interface no ChatGPT, vá para Crie um servidor MCP e não registre um recurso de interface.
Em um novo diretório, crie um arquivo chamado public/todo-widget.html.
Quando a ferramenta MCP associada retornar essa interface, o ChatGPT a exibirá.
Esse arquivo conterá o componente web que aparecerá na interface do ChatGPT.
Adicione o conteúdo abaixo ao arquivo.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Todo list</title>
<style>
:root {
color: #0b0b0f;
font-family:
"Inter",
system-ui,
-apple-system,
sans-serif;
}
html,
body {
width: 100%;
min-height: 100%;
box-sizing: border-box;
}
body {
margin: 0;
padding: 16px;
background: #f6f8fb;
}
main {
width: 100%;
max-width: 360px;
min-height: 260px;
margin: 0 auto;
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
}
h2 {
margin: 0 0 16px;
font-size: 1.25rem;
}
form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
form input {
flex: 1;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #cad3e0;
font-size: 0.95rem;
}
form button {
border: none;
border-radius: 10px;
background: #111bf5;
color: white;
font-weight: 600;
padding: 0 16px;
cursor: pointer;
}
form button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
input[type="checkbox"] {
accent-color: #111bf5;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
li {
background: #f2f4fb;
border-radius: 12px;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
}
li span {
flex: 1;
}
li[data-completed="true"] span {
text-decoration: line-through;
color: #6c768a;
}
li[data-busy="true"] {
opacity: 0.7;
}
</style>
</head>
<body>
<main>
<h2>Todo list</h2>
<form id="add-form" autocomplete="off">
<input id="todo-input" name="title" placeholder="Add a task" />
<button type="submit">Add</button>
</form>
<ul id="todo-list"></ul>
</main>
<script type="module">
const listEl = document.querySelector("#todo-list");
const formEl = document.querySelector("#add-form");
const inputEl = document.querySelector("#todo-input");
const addButtonEl = formEl.querySelector('button[type="submit"]');
const addButtonText = addButtonEl.textContent;
let tasks = [];
let isAdding = false;
const busyTodoIds = new Set();
const render = () => {
listEl.innerHTML = "";
tasks.forEach((task) => {
const li = document.createElement("li");
li.dataset.id = task.id;
li.dataset.completed = String(Boolean(task.completed));
li.dataset.busy = String(busyTodoIds.has(task.id));
const label = document.createElement("label");
label.style.display = "flex";
label.style.alignItems = "center";
label.style.gap = "10px";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = Boolean(task.completed);
checkbox.disabled = busyTodoIds.has(task.id);
const span = document.createElement("span");
span.textContent = task.title;
label.appendChild(checkbox);
label.appendChild(span);
li.appendChild(label);
listEl.appendChild(li);
});
};
const updateFromResponse = (response) => {
if (response?.structuredContent?.tasks) {
tasks = response.structuredContent.tasks;
render();
}
};
// MCP Apps standard bridge: JSON-RPC messages over postMessage.
//
// - Initialize the bridge with `ui/initialize`.
// - Confirm readiness with `ui/notifications/initialized`.
// - Call tools with `tools/call`.
// - Listen for `ui/notifications/tool-result` to react to model-initiated tool calls.
let rpcId = 0;
const pendingRequests = new Map();
const rpcNotify = (method, params) => {
window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*");
};
const rpcRequest = (method, params) =>
new Promise((resolve, reject) => {
const id = ++rpcId;
pendingRequests.set(id, { resolve, reject });
window.parent.postMessage(
{ jsonrpc: "2.0", id, method, params },
"*"
);
});
window.addEventListener(
"message",
(event) => {
if (event.source !== window.parent) return;
const message = event.data;
if (!message || message.jsonrpc !== "2.0") return;
// Responses
if (typeof message.id === "number") {
const pending = pendingRequests.get(message.id);
if (!pending) return;
pendingRequests.delete(message.id);
if (message.error) {
pending.reject(message.error);
return;
}
pending.resolve(message.result);
return;
}
// Notifications
if (typeof message.method !== "string") return;
if (message.method === "ui/notifications/tool-result") {
updateFromResponse(message.params);
}
},
{ passive: true }
);
const initializeBridge = async () => {
const appInfo = { name: "todo-widget", version: "0.1.0" };
const appCapabilities = {};
const protocolVersion = "2026-01-26";
try {
await rpcRequest("ui/initialize", {
appInfo,
appCapabilities,
protocolVersion,
});
rpcNotify("ui/notifications/initialized", {});
} catch (error) {
console.error("Failed to initialize the MCP Apps bridge:", error);
throw error;
}
};
const bridgeReady = initializeBridge();
const callTodoTool = async (name, payload) => {
await bridgeReady;
const response = await rpcRequest("tools/call", {
name,
arguments: payload,
});
updateFromResponse(response);
};
formEl.addEventListener("submit", async (event) => {
event.preventDefault();
const title = inputEl.value.trim();
if (!title || isAdding) return;
isAdding = true;
addButtonEl.disabled = true;
addButtonEl.textContent = "Adding…";
try {
await callTodoTool("add_todo", { title });
inputEl.value = "";
} catch (error) {
console.error("Failed to add todo:", error);
} finally {
isAdding = false;
addButtonEl.disabled = false;
addButtonEl.textContent = addButtonText;
}
});
listEl.addEventListener("change", async (event) => {
const checkbox = event.target;
if (!checkbox.matches('input[type="checkbox"]')) return;
const id = checkbox.closest("li")?.dataset.id;
if (!id) return;
if (!checkbox.checked) {
checkbox.checked = true;
return;
}
if (busyTodoIds.has(id)) return;
busyTodoIds.add(id);
checkbox.disabled = true;
const rowEl = checkbox.closest("li");
if (rowEl) rowEl.dataset.busy = "true";
try {
await callTodoTool("complete_todo", { id });
} catch (error) {
console.error("Failed to complete todo:", error);
} finally {
busyTodoIds.delete(id);
render();
}
});
render();
</script>
</body>
</html>Use o MCP Apps no componente web
Para criar uma interface, use a ponte de comunicação com o host do MCP Apps. Ela troca mensagens JSON-RPC via postMessage, com notificações ui/* e métodos como tools/call.
Depois que o fluxo comum aos hosts do MCP Apps estiver funcionando, adicione extensões opcionais do ChatGPT por meio de window.openai somente se precisar de funcionalidades que o padrão não oferece. Para saber mais, consulte Adicione uma interface ao servidor MCP.
Crie um servidor MCP
Instale o SDK oficial do MCP para Python ou Node para criar um servidor e disponibilizar um endpoint em /mcp.
Neste guia, vamos usar o SDK para Node.
Se estiver usando Python, consulte o repositório de exemplos no GitHub para ver um exemplo de servidor MCP que usa o SDK para Python.
Execute o comando abaixo para instalar o SDK para Node, os utilitários do MCP Apps e o pacote zod.
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod
Servidor MCP com recursos de interface
Registre um recurso para o pacote do componente e também as ferramentas que o modelo pode chamar (por exemplo, add_todo e complete_todo). Assim, o ChatGPT poderá controlar a interface.
Crie um arquivo chamado server.js e cole nele o exemplo abaixo, que usa o SDK para Node.
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
const todoHtml = readFileSync("public/todo-widget.html", "utf8");
const addTodoInputSchema = {
title: z.string().min(1),
};
const completeTodoInputSchema = {
id: z.string().min(1),
};
const todoOutputSchema = {
tasks: z.array(
z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
})
),
};
let todos = [];
let nextId = 1;
const replyWithTodos = (message) => ({
content: message ? [{ type: "text", text: message }] : [],
structuredContent: { tasks: todos },
});
function createTodoServer() {
const server = new McpServer({
name: "todo-plugin-server",
version: "0.1.0",
});
registerAppResource(
server,
"todo-widget",
"ui://widget/todo.html",
{},
async () => ({
contents: [
{
uri: "ui://widget/todo.html",
mimeType: RESOURCE_MIME_TYPE,
text: todoHtml,
},
],
})
);
registerAppTool(
server,
"add_todo",
{
title: "Add todo",
description: "Creates a todo item with the given title.",
inputSchema: addTodoInputSchema,
outputSchema: todoOutputSchema,
_meta: {
ui: { resourceUri: "ui://widget/todo.html" },
},
},
async (args) => {
const title = args?.title?.trim?.() ?? "";
if (!title) return replyWithTodos("Missing title.");
const todo = { id: `todo-${nextId++}`, title, completed: false };
todos = [...todos, todo];
return replyWithTodos(`Added "${todo.title}".`);
}
);
registerAppTool(
server,
"complete_todo",
{
title: "Complete todo",
description: "Marks a todo as done by id.",
inputSchema: completeTodoInputSchema,
outputSchema: todoOutputSchema,
_meta: {
ui: { resourceUri: "ui://widget/todo.html" },
},
},
async (args) => {
const id = args?.id;
if (!id) return replyWithTodos("Missing todo id.");
const todo = todos.find((task) => task.id === id);
if (!todo) {
return replyWithTodos(`Todo ${id} was not found.`);
}
todos = todos.map((task) =>
task.id === id ? { ...task, completed: true } : task
);
return replyWithTodos(`Completed "${todo.title}".`);
}
);
return server;
}
const port = Number(process.env.PORT ?? 8787);
const MCP_PATH = "/mcp";
const httpServer = createServer(async (req, res) => {
if (!req.url) {
res.writeHead(400).end("Missing URL");
return;
}
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
if (req.method === "OPTIONS" && url.pathname === MCP_PATH) {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "content-type, mcp-session-id",
"Access-Control-Expose-Headers": "Mcp-Session-Id",
});
res.end();
return;
}
if (req.method === "GET" && url.pathname === "/") {
res.writeHead(200, { "content-type": "text/plain" }).end("Todo MCP server");
return;
}
const MCP_METHODS = new Set(["POST", "GET", "DELETE"]);
if (url.pathname === MCP_PATH && req.method && MCP_METHODS.has(req.method)) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
const server = createTodoServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode
enableJsonResponse: true,
});
res.on("close", () => {
transport.close();
server.close();
});
try {
await server.connect(transport);
await transport.handleRequest(req, res);
} catch (error) {
console.error("Error handling MCP request:", error);
if (!res.headersSent) {
res.writeHead(500).end("Internal server error");
}
}
return;
}
res.writeHead(404).end("Not Found");
});
httpServer.listen(port, () => {
console.log(
`Todo MCP server listening on http://localhost:${port}${MCP_PATH}`
);
});O código também responde a GET / para verificar se o servidor está funcionando, trata requisições preliminares de CORS para /mcp e retorna 404 Not Found para as rotas de descoberta do OAuth que você ainda não usa. Isso evita que o ChatGPT exiba erros 502 enquanto você desenvolve o servidor sem autenticação.
Execute o servidor localmente
Se você usa um framework web como o React, compile o componente para gerar arquivos estáticos que o template HTML possa incorporar diretamente.
Em geral, um comando de compilação como npm run build gera um diretório dist com os arquivos compilados.
Como este guia usa HTML puro, não é preciso compilar o componente.
Inicie o servidor MCP em http://localhost:<port>/mcp a partir do diretório que contém server.js (ou server.ts).
Confira se a configuração "type": "module" está no arquivo package.json.
{
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.20.2",
"@modelcontextprotocol/ext-apps": "^1.0.1",
"zod": "^3.25.76"
}
}
Em seguida, execute o servidor com o comando abaixo.
node server.js
Quando estiver pronto, o servidor deverá exibir a mensagem Todo MCP server listening on http://localhost:8787/mcp.
Teste com o MCP Inspector
Você pode usar o MCP Inspector para testar o servidor localmente.
npx @modelcontextprotocol/inspector@latest
O comando abre a interface do MCP Inspector. Selecione Streamable HTTP, insira http://localhost:8787/mcp e conecte-se para testar o servidor e inspecionar as respostas das ferramentas.

Disponibilize o servidor na internet pública
Para que o ChatGPT acesse o servidor durante o desenvolvimento, você precisa disponibilizá-lo na internet pública. Você pode usar uma ferramenta como o ngrok para abrir um túnel até o servidor local.
ngrok http <port>
O comando gera uma URL pública, como https://<subdomain>.ngrok.app, que permite acessar o servidor pelo ChatGPT.
Ao conectar o servidor MCP no ChatGPT, informe a URL pública com o caminho /mcp (por exemplo, https://<subdomain>.ngrok.app/mcp).
Conecte o servidor MCP no ChatGPT
Quando o servidor MCP e o componente web estiverem funcionando localmente, siga estas etapas para conectar o servidor no ChatGPT.
- Acesse Plug-ins do ChatGPT, selecione o botão com o sinal de mais e depois Adicionar servidor MCP personalizado.
- Cole a URL HTTPS do túnel ou da implantação com o caminho
/mcp(por exemplo,https://<subdomain>.ngrok.app/mcp), dê um nome à conexão e configure a autenticação. - Leia o aviso sobre os riscos, selecione Entendo e quero continuar e depois Criar como plugin.
- Instale o plugin criado, abra um novo chat, digite
@no campo de mensagem e selecione o plugin. Envie um prompt ao modelo, por exemplo, “Adicione uma nova tarefa para eu ler meu livro”. O ChatGPT transmitirá os dados das ferramentas em tempo real para que você possa conferir as entradas e saídas.
Próximos passos
A partir daqui, você pode aprimorar a interface, a experiência de uso, os prompts, os metadados das ferramentas e o funcionamento do plugin como um todo.
Atualize a conexão do plugin sempre que fizer alterações no servidor MCP, como mudar as ferramentas, os metadados ou as configurações relacionadas. Você pode fazer isso na página de detalhes do plugin em chatgpt.com/plugins.
Ao se preparar para a distribuição pública, consulte Envie plugins, as diretrizes para plugins e o guia para explorar casos de uso de plugins. Se estiver criando uma interface, consulte também as diretrizes de interface.
Depois de dominar os conceitos básicos, você poderá criar interfaces com mais recursos, autenticar usuários quando necessário e gerenciar o estado.