De MCP a APP visual en ChatGPT
La ia sigue avanzando a la velocidad de la luz y ahora podemos desarrollar aplicaciones para el propio ChatGPT. Sin duda este tema me lleva apasionando desde que lo lei por primera vez y por eso quiero dejar un pequeño tutorial de como hacer nuestra primera App en chatgpt.
En la Parte 1 construimos un MCP server funcional: tools contra PokéAPI, transporte HTTP y pruebas en Cursor. El modelo podía consultar Pokémon, pero la respuesta era solo texto.
En esta segunda parte damos el salto que OpenAI recomienda después de tener las tools estables: añadir una interfaz visual que se renderiza dentro de ChatGPT como un iframe (estándar MCP Apps).
Al terminar tendrás:
Una tarjeta visual de Pokémon (sprite, tipos, habilidades, stats).
El patrón desacoplado data tool + render tool.
Preview local sin ChatGPT.
El conector funcionando en ChatGPT Developer mode con ngrok.
1. ¿Por qué añadir UI?
Un MCP server no necesita UI para ser útil. Si el usuario solo quiere saber el tipo de Pikachu, basta con get_pokemon y una respuesta en texto.
Pero hay casos donde la UI aporta mucho o un extra para nuestro negocio. Por ejemplo en el mercado ya podemos ver empresas que tienen sus apps dentro de chatGPT.

De esta manera puedes por ejemplo preguntar dentro de chatGPT por un vuelo con el plugin de eDreams y llevar trafico a tu web desde el propio chatGPT.
En mi opinión ya se esta viendo un cambio drástico en las búsquedas y en el uso que hace la gente de buscadores, cambiando las búsquedas por chatGPT. Tener una app aquí puede que nos aporte trafico y ventas.
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 JavaScript del iframe recibe el resultado del tool vía notificaciones MCP Apps.
Estándar vs compatibilidad ChatGPT
OpenAI recomienda usar el estándar MCP Apps primero. ChatGPT también expone aliases de compatibilidad (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 demasiado a menudo.
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 los datos 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_pokemonde nuevo (buscador) sin remontar el iframe completo.
En nuestro server.ts, get_pokemon dice explícitamente en la descripción:
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 # Estilos
│ │ └── widget-entry.ts # Entry + inyección CSS
│ └── dist/widget.js # Generado por esbuild (no en git)
│
└── images/ # Capturas para docs
Dependencia nueva en el server:
npm install @modelcontextprotocol/ext-apps
Este paquete aporta helpers registerAppTool, registerAppResource y la constante RESOURCE_MIME_TYPE (text/html;profile=mcp-app).
5. Paso 1: registrar el resource HTML
Un resource es la plantilla HTML que ChatGPT cargará en el iframe. Lo registramos con registerAppResource:
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 PokéAPI
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
],
},
},
},
},
],
})
);
Qué hace cada parte
WIDGET_URI— Identificador estable. Trátalo como clave de caché: si cambias JS/CSS de forma incompatible, publica una URI nueva (pokemon-card-v2.html).RESOURCE_MIME_TYPE— Le dice al host que esto es una MCP App, no HTML suelto.buildWidgetHtml()— Leeweb/dist/widget.jsy lo inyecta en un<script>dentro del HTML.prefersBorder: true— Pide a ChatGPT un borde alrededor del iframe (mejor presentación inline).csp.resourceDomains— Dominios desde los que el iframe puede cargar imágenes, fuentes, etc.
El HTML base vive en widget-resource.ts. Es markup estático (contenedores con ids) + el bundle JS embebido:
<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.
Usamos registerAppTool (wrapper de @modelcontextprotocol/ext-apps):
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— Es lo que conecta esta tool con el HTML registrado antes.Mismo schema que
get_pokemon— El modelo puede pasar elstructuredContentde la tool anterior casi tal cual.Texto en
content— Le decimos al modelo que no repita los datos en tabla markdown; la UI ya los muestra.openai/toolInvocation/invoking— Texto que ve el usuario mientras se renderiza ("Rendering Pokémon card…").El handler de render no llama a PokéAPI — Solo presenta datos ya obtenidos. La lógica de negocio sigue en
get_pokemon.
Las tools de datos (get_pokemon, etc.) 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 (sin React en nuestro caso: TypeScript + CSS + DOM).
web/package.json:
{
"scripts": {
"build": "esbuild src/widget-entry.ts --bundle --format=iife --platform=browser --minify --loader:.css=text --outfile=dist/widget.js"
}
}
Entry point:
// 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 del proyecto:
npm --prefix web install
npm run build:web
Crítico: sin
npm run build:web, el server fallará al arrancar porquewidget-resource.tsleeweb/dist/widget.js.
El bundle queda en ~12 KB minificado. ChatGPT lo recibe inline dentro del HTML del resource (no hay URL externa del JS del widget en producción).
8. Paso 4: el puente MCP Apps en el iframe
El widget corre dentro de un iframe. No puede llamar a tu API directamente como quiera: habla con el host (ChatGPT) vía postMessage y JSON-RPC.
Recibir datos del tool
Escuchamos la notificación estándar ui/notifications/tool-result:
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 comprobamos window.openai.toolOutput al cargar (útil en preview local y compatibilidad ChatGPT).
Llamar tools desde el widget
Cuando el usuario pulsa Buscar otro Pokémon, 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 {
// Fallback ChatGPT
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: llama get_type con el tipo principal del Pokémon actual y pinta fortalezas/debilidades.
Normalizar structuredContent
Los hosts a veces anidan el payload. Por eso tenemos unwrapStructuredContent:
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
ChatGPT necesita saber cuánto mide el iframe:
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 Content Security Policy. Si tu widget carga sprites de raw.githubusercontent.com o fuentes de Google Fonts, debes declararlo:
_meta: {
ui: {
csp: {
resourceDomains: [
"https://raw.githubusercontent.com",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
],
},
},
},
Si omites un dominio, verás imágenes rotas o fuentes por defecto sin error en tu código local.
Regla: mantén la allowlist lo más estrecha posible. Solo los dominios que realmente usas.
10. Paso 6: preview local sin ChatGPT
Probar el widget solo en ChatGPT + ngrok es lento. Mejor un ciclo local:
npm run build:web
npm run dev:http
Rutas útiles:
URL | Qué hace |
|---|---|
Pikachu mock | |
Pikachu real desde PokéAPI | |
Endpoint MCP |
preview-widget.ts inyecta datos fake simulando lo que haría ChatGPT:
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 pero ChatGPT falla, el problema casi seguro está en el host/iframe, no en tu CSS.
11. Paso 7: probar en ChatGPT
Requisitos
Server HTTP corriendo (
npm run dev:http)Túnel HTTPS (ngrok, Cloudflare Tunnel, etc.)
Developer mode activado en ChatGPT
Pasos
Expón el puerto:
ngrok http 8787Copia la URL HTTPS, por ejemplo
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 Pokémon MCP.
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 de Charmander (como en la captura de abajo).

Tras cambiar código
npm run build:webReinicia
npm run dev:httpEn ChatGPT: Refresh en el connector
Si cambias el HTML/JS de forma incompatible, considera bump de URI (ui://widget/pokemon-card-v2.html).
12. Flujo completo de una petición
┌─────────────┐ "carta de charmander" ┌──────────────┐
│ Usuario │ ─────────────────────────────► │ ChatGPT │
└─────────────┘ └──────┬───────┘
│
tools/call get_pokemon │
▼
┌──────────────┐
│ MCP server │
│ + PokéAPI │
└──────┬───────┘
│
structuredContent │
{ id:4, name:"charmander"… } │
▼
┌──────────────┐
│ ChatGPT │
│ (modelo) │
└──────┬───────┘
│
tools/call render_pokemon_widget │
▼
┌──────────────┐
│ MCP server │
│ devuelve │
│ resourceUri │
└──────┬───────┘
│
GET resource ui://widget/… │
HTML + widget.js embebido │
▼
┌──────────────┐
│ iframe │
│ (tarjeta) │
└──────────────┘
Interacción posterior desde el iframe (buscador):
iframe ── tools/call get_pokemon ──► MCP server ──► PokéAPI
iframe ◄── structuredContent ────── MCP server
(tarjeta actualizada sin remontar el iframe principal)