Introducción
Los complementos usan el Model Context Protocol (MCP) para que ChatGPT y Codex puedan acceder a las funciones que ofrece un servidor. En este tutorial usaremos:
- Un servidor MCP que define herramientas y las pone a disposición de ChatGPT y Codex.
- Un componente web opcional que se muestra en un iframe dentro de ChatGPT.
ChatGPT implementa el estándar abierto de interfaces de usuario de MCP Apps, por lo que puedes crear tu interfaz una sola vez y ejecutarla en distintos hosts compatibles con MCP Apps.
En esta guía crearemos un flujo de trabajo básico para gestionar tareas pendientes. La interfaz estará en un solo archivo HTML que reunirá el marcado, el CSS y el JavaScript.
Para ver ejemplos más avanzados con React, consulta el repositorio de ejemplos en GitHub.
Crear un componente web
Este paso es opcional. Si solo necesitas herramientas y puedes prescindir de la interfaz en ChatGPT, ve directamente a Crear un servidor MCP y omite el registro del recurso de interfaz.
Para empezar, crea un archivo llamado public/todo-widget.html en un directorio nuevo.
ChatGPT mostrará la interfaz cuando la herramienta MCP asociada la devuelva.
Este archivo contendrá el componente web que se mostrará en la interfaz de ChatGPT.
Agrega el siguiente contenido:
<!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>Usar MCP Apps en tu componente web
Al crear una interfaz, usa el puente de comunicación de MCP Apps con el host. Este puente usa JSON-RPC sobre postMessage, con notificaciones ui/* y métodos como tools/call.
Una vez que funcione el flujo común de MCP Apps, agrega extensiones opcionales de ChatGPT mediante window.openai solo si necesitas funciones que el estándar no contempla. Para obtener más información, consulta Agregar una interfaz de usuario a tu servidor MCP.
Crear un servidor MCP
Instala el SDK oficial de MCP para Python o Node para crear un servidor y exponer el punto de acceso /mcp.
En esta guía usaremos el SDK para Node.
Si usas Python, consulta nuestro repositorio de ejemplos en GitHub para ver un ejemplo de servidor MCP creado con el SDK para Python.
Instala el SDK para Node, las utilidades de MCP Apps y el paquete zod con el siguiente comando:
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod
Servidor MCP con recursos de interfaz de usuario
Registra un recurso para el paquete de tu componente y las herramientas que puede invocar el modelo (por ejemplo, add_todo y complete_todo) para que ChatGPT pueda controlar la interfaz.
Crea un archivo llamado server.js y pega el siguiente ejemplo que usa el 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}`
);
});Este código también responde a GET / para las comprobaciones de estado, procesa las solicitudes preliminares de CORS para /mcp y devuelve 404 Not Found en las rutas de descubrimiento de OAuth que aún no usas. Esto evita que ChatGPT muestre errores 502 mientras sigues desarrollando sin autenticación.
Ejecutar el servidor de forma local
Si usas un framework web como React, compila tu componente para generar recursos estáticos que la plantilla HTML pueda incorporar directamente.
Por lo general, puedes ejecutar un comando de compilación como npm run build para generar un directorio dist con los recursos compilados.
En esta guía usamos HTML sin frameworks, así que no hace falta compilar el componente.
Inicia el servidor MCP en http://localhost:<port>/mcp desde el directorio que contiene server.js (o server.ts).
Asegúrate de que "type": "module" figure en tu archivo package.json:
{
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.20.2",
"@modelcontextprotocol/ext-apps": "^1.0.1",
"zod": "^3.25.76"
}
}
Luego ejecuta el servidor con el siguiente comando:
node server.js
Cuando esté listo, el servidor debería mostrar Todo MCP server listening on http://localhost:8787/mcp.
Probar el servidor con MCP Inspector
Puedes usar MCP Inspector para probar tu servidor de forma local.
npx @modelcontextprotocol/inspector@latest
Este comando abre la interfaz de MCP Inspector. Selecciona Streamable HTTP, ingresa http://localhost:8787/mcp y conéctate para probar tu servidor e inspeccionar las respuestas de sus herramientas.

Habilitar el acceso a tu servidor desde Internet
Para que ChatGPT pueda acceder a tu servidor durante el desarrollo, debes hacerlo accesible desde Internet. Puedes usar una herramienta como ngrok para abrir un túnel hacia tu servidor local.
ngrok http <port>
Obtendrás una URL pública como https://<subdomain>.ngrok.app que podrás usar para acceder a tu servidor desde ChatGPT.
Cuando conectes tu servidor MCP en ChatGPT, ingresa la URL pública con la ruta /mcp (por ejemplo, https://<subdomain>.ngrok.app/mcp).
Conectar tu servidor MCP en ChatGPT
Una vez que tu servidor MCP y tu componente web funcionen de forma local, conecta el servidor en ChatGPT:
- Ve a Complementos de ChatGPT, selecciona el botón con el signo más y luego Agregar servidor MCP personalizado.
- Pega la URL HTTPS de tu túnel o despliegue con la ruta
/mcp(por ejemplo,https://<subdomain>.ngrok.app/mcp), asigna un nombre a la conexión y configura la autenticación. - Revisa la advertencia sobre los riesgos, selecciona Entiendo y quiero continuar y luego Crear como complemento.
- Instala el complemento que acabas de crear, abre un chat nuevo, escribe
@en el campo del prompt y selecciona el complemento. Dale una instrucción al modelo (por ejemplo, “Agrega una tarea nueva para leer mi libro”). ChatGPT transmitirá los datos de las herramientas a medida que estén disponibles para que puedas verificar las entradas y salidas.
Próximos pasos
A partir de aquí, puedes seguir ajustando la interfaz y la experiencia de usuario, los prompts, los metadatos de las herramientas y la experiencia en general.
Actualiza la conexión del complemento cada vez que modifiques el servidor MCP, ya sea que cambies las herramientas, los metadatos o la configuración relacionada. Puedes hacerlo desde la página de detalles del complemento en chatgpt.com/plugins.
Cuando prepares tu complemento para distribuirlo públicamente, consulta Enviar complementos para su revisión, las Pautas para complementos y la guía para generar ideas de casos de uso para complementos. Si estás creando una interfaz, también puedes consultar las pautas de diseño de interfaces.
Una vez que domines los conceptos básicos, podrás crear interfaces más completas, autenticar a los usuarios cuando sea necesario y gestionar el estado.