Cómo crear una app visual en ChatGPT con MCP
Cómo crear una app visual en ChatGPT a partir de un MCP: plugins, interfaz y el flujo completo para publicar tu primera aplicación real dentro del chat.

Cómo crear una app visual en ChatGPT con MCP Apps: un widget HTML dentro del chat, el patrón data tool + render tool, preview local y el conector con ngrok.
Serie MCP:
Qué es un MCP — conceptos
Cómo crear un MCP — server + tools (Parte 1)
App visual en ChatGPT (este) — UI / MCP Apps
Código completo: github.com/omy13/mcp-ui-openai-pokemon. Clona el repo o síguelo paso a paso abajo.
En la Parte 1 montamos un MCP funcional: tools contra PokéAPI, transporte HTTP y pruebas en Cursor. El modelo podía consultar Pokémon, pero la respuesta era solo texto.
Aquí damos el salto que OpenAI recomienda cuando las tools ya están estables: añadir una interfaz visual que ChatGPT renderiza como iframe (estándar MCP Apps).
Al terminar tendrás:
Una tarjeta visual (sprite, tipos, habilidades, stats).
El patrón desacoplado data tool + render tool.
Preview local sin ChatGPT.
El conector en ChatGPT Developer mode con ngrok.
Requisitos
Haber hecho la Parte 1 o clonar este repo.
Node.js 18+.
Túnel HTTPS (ngrok, Cloudflare Tunnel…).
ChatGPT con Developer mode.
1. ¿Por qué añadir UI?
Un MCP server no necesita UI para ser útil. Si solo quieres el tipo de Pikachu, basta get_pokemon y texto.
Pero hay casos donde la UI aporta mucho —o un extra de producto—. En el marketplace ya ves empresas con apps dentro de ChatGPT:

Puedes, por ejemplo, preguntar por un vuelo con un connector y acabar en su web desde el propio chat.
En mi opinión, cada vez más gente resuelve dudas en ChatGPT (u otros chats) en lugar de abrir diez pestañas. Estar ahí con una app útil es una forma de aparecer en ese flujo.
Regla de oro
Las tools deben seguir siendo útiles sin componente. ChatGPT, Cursor u otros hosts pueden no renderizar UI; el workflow no debe romperse.
2. Cómo funciona MCP Apps
MCP Apps es el estándar abierto que define cómo un MCP server devuelve recursos HTML asociados a tools concretas. ChatGPT implementa este estándar.

Piezas clave:
Tu MCP server
│
├── Tools de datos (get_pokemon) → structuredContent, sin UI
│
├── Tool de render (render_…) → _meta.ui.resourceUri
│
└── Resource HTML (ui://widget/…) → iframe en ChatGPT
│
└── JavaScript del widget → postMessage ↔ host
Qué ve ChatGPT
El modelo llama
render_pokemon_widget.ChatGPT lee
_meta.ui.resourceUri(por ejemploui://widget/pokemon-card.html).Pide ese resource al MCP server.
Recibe HTML con MIME type
text/html;profile=mcp-app.Lo monta en un iframe inline junto al mensaje.
El JS del iframe recibe el resultado del tool vía notificaciones MCP Apps.
Estándar vs compatibilidad ChatGPT
OpenAI recomienda el estándar MCP Apps primero. ChatGPT también expone alias (window.openai.toolOutput, window.openai.callTool, etc.). Nuestro widget usa el puente estándar y cae en window.openai como fallback.
Objetivo | Estándar MCP Apps | Alias ChatGPT |
|---|---|---|
Vincular tool ↔ UI |
|
|
Recibir resultado |
|
|
Llamar tool desde UI |
|
|
3. El patrón desacoplado: datos vs render
Este es el punto más importante del diseño.
El anti-patrón
Si cada tool lleva_meta.ui.resourceUri, ChatGPT puede re-renderizar el iframe.

El patrón recomendado
Separar en dos capas:
Capa | Tools | Tiene UI |
|---|---|---|
Datos |
| No |
Render |
| Sí |
Flujo ideal:
Usuario: "Muéstrame la carta de charmander"
│
▼
1. get_pokemon("charmander")
→ { id: 4, name: "charmander", types: ["fire"], stats: [...], ... }
│
▼
2. render_pokemon_widget(mismos campos)
→ structuredContent + resourceUri
│
▼
3. ChatGPT monta el iframe UNA vez con los datos finales
Ventajas:
El modelo valida y filtra antes de renderizar.
El widget no se remonta en cada búsqueda intermedia.
Las tools de datos siguen siendo reutilizables.
Desde el widget puedes llamar
get_pokemonotra vez (buscador) sin remontar el iframe.
En server.ts, get_pokemon lo deja claro en la description:
Data-only tool: after this, call render_pokemon_widget to show the visual card.
Y las instructions del server refuerzan el orden.
4. Nueva estructura del proyecto
Separamos server MCP y frontend del widget, como recomienda OpenAI:
mcp-ui-openai-pokemon/
├── src/ # MCP server (Node)
│ ├── server.ts # Tools + registerAppResource
│ ├── widget-resource.ts # HTML que embebe el bundle JS
│ ├── preview-widget.ts # Mock + bootstrap para /preview
│ └── http.ts # /mcp + /preview
│
├── web/ # Widget (browser)
│ ├── src/
│ │ ├── widget.ts # Lógica + puente MCP Apps
│ │ ├── widget.css
│ │ └── widget-entry.ts
│ └── dist/widget.js # esbuild (no en git)
│
└── images/
Dependencia nueva:
npm install @modelcontextprotocol/ext-apps
Aporta registerAppTool, registerAppResource y RESOURCE_MIME_TYPE (text/html;profile=mcp-app).
Camino A — clonar
git clone https://github.com/omy13/mcp-ui-openai-pokemon.git
cd mcp-ui-openai-pokemon
npm install
npm run build:web
npm run dev:http
Abre http://localhost:8787/preview y deberías ver la tarjeta mock. Luego sigue los pasos para entender cada pieza (o salta a probar en ChatGPT).
5. Paso 1: registrar el resource HTML
Un resource es la plantilla HTML que ChatGPT cargará en el iframe:
import {
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { buildWidgetHtml, WIDGET_URI } from "./widget-resource.js";
export const WIDGET_URI = "ui://widget/pokemon-card.html";
registerAppResource(
server,
"pokemon-card-widget",
WIDGET_URI,
{},
async () => ({
contents: [
{
uri: WIDGET_URI,
mimeType: RESOURCE_MIME_TYPE,
text: buildWidgetHtml(),
_meta: {
ui: {
prefersBorder: true,
csp: {
resourceDomains: [
"https://raw.githubusercontent.com", // sprites
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
],
},
},
},
},
],
})
);
Qué hace cada parte
WIDGET_URI— Identificador estable. Trátalo como clave de caché: si rompes compatibilidad JS/CSS, publicapokemon-card-v2.html.RESOURCE_MIME_TYPE— Le dice al host que esto es una MCP App.buildWidgetHtml()— Leeweb/dist/widget.jsy lo mete en un<script>dentro del HTML.prefersBorder: true— Borde alrededor del iframe.csp.resourceDomains— Dominios desde los que el iframe puede cargar assets.
HTML base (idea):
<div class="widget" id="app">
<div id="pokemon-card" hidden>
<img class="sprite" id="sprite" />
<h1 class="name" id="name"></h1>
<!-- stats, abilities, buscador… -->
</div>
</div>
<script>
/* contenido de web/dist/widget.js */
</script>
6. Paso 2: la tool de render
Solo una tool lleva el vínculo con la UI: render_pokemon_widget.
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
const WIDGET_TOOL_META = {
ui: { resourceUri: WIDGET_URI },
"openai/toolInvocation/invoking": "Rendering Pokémon card…",
"openai/toolInvocation/invoked": "Pokémon card ready.",
};
registerAppTool(
server,
"render_pokemon_widget",
{
title: "Render Pokémon widget",
description:
"Render the visual Pokémon card. Always call get_pokemon first, then pass its fields to this tool.",
inputSchema: {
id: z.number().int(),
name: z.string(),
height: z.number(),
weight: z.number(),
types: z.array(z.string()),
abilities: z.array(
z.object({ name: z.string(), isHidden: z.boolean() })
),
stats: z.array(
z.object({ name: z.string(), baseStat: z.number().int() })
),
spriteUrl: z.string().nullable(),
speciesUrl: z.string(),
},
outputSchema: pokemonOutputSchema,
annotations: READ_ONLY,
_meta: WIDGET_TOOL_META,
},
async (pokemon) => ({
structuredContent: pokemon,
content: [
{
type: "text",
text: `Showing card for ${pokemon.name} (#${pokemon.id}). Do not repeat the stats as a markdown table.`,
},
],
})
);
Detalles que importan
_meta.ui.resourceUri— Conecta esta tool con el HTML.Mismo schema que
get_pokemon— El modelo puede pasar elstructuredContentanterior casi tal cual.Texto en
content— Pedimos que no repita los datos en markdown; la UI ya los muestra.openai/toolInvocation/invoking— Texto mientras renderiza.El handler de render no llama a PokéAPI — Solo presenta datos. La lógica sigue en
get_pokemon.
Las tools de datos usan server.registerTool normal, sin _meta.ui.
7. Paso 3: construir el widget con esbuild
El widget vive en web/ y se compila a un único widget.js con esbuild (TypeScript + CSS + DOM; sin React en este ejemplo).
web/package.json:
{
"scripts": {
"build": "esbuild src/widget-entry.ts --bundle --format=iife --platform=browser --minify --loader:.css=text --outfile=dist/widget.js"
}
}
Entry:
// web/src/widget-entry.ts
import { mountWidget } from "./widget.js";
import cssText from "./widget.css";
const style = document.createElement("style");
style.textContent = cssText;
document.head.appendChild(style);
mountWidget();
Desde la raíz:
npm --prefix web install
npm run build:web
Crítico
Sinnpm run build:web, el server petará al arrancar:widget-resource.tsleeweb/dist/widget.js.
El bundle queda en ~12 KB minificado. ChatGPT lo recibe inline dentro del HTML del resource.
8. Paso 4: el puente MCP Apps en el iframe
El widget corre dentro de un iframe. No llama a tu API a lo loco: habla con el host vía postMessage y JSON-RPC.

Recibir datos del tool
window.addEventListener("message", (event) => {
if (event.source !== window.parent) return;
const message = event.data;
if (!message || message.jsonrpc !== "2.0") return;
if (message.method === "ui/notifications/tool-result") {
render(normalizePayload(message.params));
}
});
También miramos window.openai.toolOutput al cargar (preview local + compat ChatGPT).
Llamar tools desde el widget
Cuando el usuario pulsa Buscar, el widget llama get_pokemon sin remontar el iframe:
function rpcRequest(method: string, params: unknown) {
const id = ++rpcId;
window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*");
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
});
}
async function callTool(name: string, args: unknown) {
try {
return await rpcRequest("tools/call", { name, arguments: args });
} catch {
return window.openai?.callTool?.(name, args);
}
}
Flujo del buscador:
Usuario escribe
bulbasaury pulsa Buscar.Widget →
tools/call→get_pokemon({ nameOrId: "bulbasaur" }).Recibe
structuredContenty actualiza la tarjeta in situ.
Lo mismo para Matchups con get_type.
Normalizar structuredContent
Los hosts a veces anidan el payload:
function unwrapStructuredContent(value: unknown): unknown {
let v = value;
for (let depth = 0; depth < 3; depth++) {
if (v?.structuredContent) v = v.structuredContent;
else if (v?.result?.structuredContent) v = v.result.structuredContent;
else break;
}
return v;
}
Informar altura al host
function reportSize() {
const height = Math.ceil(
document.documentElement.getBoundingClientRect().height
);
window.parent.postMessage(
{
jsonrpc: "2.0",
method: "ui/notifications/size-changed",
params: { width: null, height },
},
"*"
);
}
Llama a reportSize() después de cada render.
9. Paso 5: CSP y dominios permitidos
El iframe de ChatGPT tiene CSP. Si cargas sprites o fuentes externas, decláralo:
_meta: {
ui: {
csp: {
resourceDomains: [
"https://raw.githubusercontent.com",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
],
},
},
},
Si omites un dominio: imágenes rotas o fuentes por defecto, sin error claro en local. Mantén la allowlist estrecha.
10. Paso 6: preview local sin ChatGPT
Probar solo con ChatGPT + ngrok es lento. Mejor ciclo local:
npm run build:web
npm run dev:http
URL | Qué hace |
|---|---|
Pikachu mock | |
Pikachu real desde PokéAPI | |
Endpoint MCP |
preview-widget.ts inyecta datos fake como haría el host:
export function buildWidgetPreviewHtml(toolOutput: WidgetPreviewData): string {
const payload = JSON.stringify(toolOutput).replace(/</g, "\\u003c");
const bootstrap = `<script>window.openai = { toolOutput: ${payload} }</script>`;
return buildWidgetHtml().replace("</head>", `${bootstrap}\n </head>`);
}
Si /preview se ve bien y ChatGPT falla, el problema casi seguro está en el host/iframe, no en tu CSS.
11. Paso 7: probar en ChatGPT
Requisitos
npm run dev:httpcorriendoTúnel HTTPS
Developer mode en ChatGPT
Pasos
Expón el puerto:
ngrok http 8787
Copia la URL HTTPS (
https://abc123.ngrok-free.app).En ChatGPT:
Settings → Apps & Connectors → Advanced → Developer mode ✓
Connectors → Create
URL:
https://abc123.ngrok-free.app/mcp
Chat nuevo → activa el connector.
Escribe:
Muéstrame la carta de charmander
Qué debería pasar
ChatGPT llama
get_pokemon.Luego
render_pokemon_widgetcon los mismos campos.Aparece el iframe con la tarjeta.

Tras cambiar código
npm run build:webReinicia
npm run dev:httpEn ChatGPT: Refresh del connector
Si rompes compatibilidad del HTML/JS, bump de URI: ui://widget/pokemon-card-v2.html.
12. Flujo completo de una petición

Usuario ── "carta de charmander" ──► ChatGPT
│
tools/call get_pokemon
▼
MCP + PokéAPI
│
structuredContent
▼
ChatGPT
│
tools/call render_pokemon_widget
▼
MCP → resourceUri
│
HTML + widget.js
▼
iframe (tarjeta)
Desde el iframe (buscador):
iframe ── tools/call get_pokemon ──► MCP ──► PokéAPI
iframe ◄── structuredContent ────── MCP
(tarjeta actualizada sin refrescar el iframe)
Si algo falla
Síntoma | Mira esto |
|---|---|
Server no arranca | ¿Hiciste |
Sprites/fuentes rotos en ChatGPT | CSP |
Connector “raro” tras un cambio | Refresh del connector; ¿caché de URI? |
Preview OK, ChatGPT KO | Host/iframe / túnel HTTPS, no el CSS |
| Ruta absoluta de Node si usas nvm |
Siguiente paso
Con esto cierras el circuito:
App visual en ChatGPT (este)
Repo para clonar o forkear: github.com/omy13/mcp-ui-openai-pokemon.