IA

De MCP a APP visual en ChatGPT

· 10 min de lectura

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.

plugins chatgpt reales

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

  1. El modelo llama render_pokemon_widget.

  2. ChatGPT lee _meta.ui.resourceUri (por ejemplo ui://widget/pokemon-card.html).

  3. Pide ese resource al MCP server.

  4. Recibe HTML con MIME type text/html;profile=mcp-app.

  5. Lo monta en un iframe inline junto al mensaje.

  6. 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

_meta.ui.resourceUri

_meta["openai/outputTemplate"]

Recibir resultado

ui/notifications/tool-result

window.openai.toolOutput

Llamar tool desde UI

tools/call (JSON-RPC)

window.openai.callTool


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

get_pokemon, get_type, list_pokemon

No

Render

render_pokemon_widget

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_pokemon de 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() — Lee web/dist/widget.js y 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

  1. _meta.ui.resourceUri — Es lo que conecta esta tool con el HTML registrado antes.

  2. Mismo schema que get_pokemon — El modelo puede pasar el structuredContent de la tool anterior casi tal cual.

  3. Texto en content — Le decimos al modelo que no repita los datos en tabla markdown; la UI ya los muestra.

  4. openai/toolInvocation/invoking — Texto que ve el usuario mientras se renderiza ("Rendering Pokémon card…").

  5. 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 porque widget-resource.ts lee web/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:

  1. Usuario escribe bulbasaur y pulsa Buscar.

  2. Widget → tools/callget_pokemon({ nameOrId: "bulbasaur" }).

  3. Recibe structuredContent y 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

http://localhost:8787/preview

Pikachu mock

http://localhost:8787/preview/live

Pikachu real desde PokéAPI

http://localhost:8787/mcp

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

  1. Expón el puerto:

    ngrok http 8787
    
  2. Copia la URL HTTPS, por ejemplo https://abc123.ngrok-free.app.

  3. En ChatGPT:

    • Settings → Apps & Connectors → Advanced → Developer mode

    • Connectors → Create

    • URL: https://abc123.ngrok-free.app/mcp

  4. Chat nuevo → activa el connector Pokémon MCP.

  5. Escribe:

    Muéstrame la carta de charmander

Qué debería pasar

  1. ChatGPT llama get_pokemon.

  2. Luego render_pokemon_widget con los mismos campos.

  3. Aparece el iframe con la tarjeta de Charmander (como en la captura de abajo).

Widget renderizado

Tras cambiar código

  1. npm run build:web

  2. Reinicia npm run dev:http

  3. En 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)


Enlaces