IA

Cómo crear una app visual en ChatGPT con MCP

· 10 min de lectura

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

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:

  1. Qué es un MCP — conceptos

  2. Cómo crear un MCP — server + tools (Parte 1)

  3. 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:

Plugins reales 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.

Arquitectura MCP Apps: tools de datos, tool de render y resource HTML en iframe

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

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

Anti-patrón vs patrón recomendado: tools de datos sin UI y una tool de render

El patrón recomendado

Separar en dos capas:

Capa

Tools

Tiene UI

Datos

get_pokemon, get_type, list_pokemon

No

Render

render_pokemon_widget

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_pokemon otra 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, publica pokemon-card-v2.html.

  • RESOURCE_MIME_TYPE — Le dice al host que esto es una MCP App.

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

  1. _meta.ui.resourceUri — Conecta esta tool con el HTML.

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

  3. Texto en content — Pedimos que no repita los datos en markdown; la UI ya los muestra.

  4. openai/toolInvocation/invoking — Texto mientras renderiza.

  5. 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
Sin npm run build:web, el server petará al arrancar: widget-resource.ts lee web/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.

Puente MCP Apps entre el iframe del widget y el host ChatGPT

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:

  1. Usuario escribe bulbasaur y pulsa Buscar.

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

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

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 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:http corriendo

  • Túnel HTTPS

  • Developer mode en ChatGPT

Pasos

  1. Expón el puerto:

ngrok http 8787
  1. Copia la URL HTTPS (https://abc123.ngrok-free.app).

  2. En ChatGPT:

    • Settings → Apps & Connectors → Advanced → Developer mode ✓

    • Connectors → Create

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

  3. Chat nuevo → activa el connector.

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

Render de la app en OpenAI

Tras cambiar código

  1. npm run build:web

  2. Reinicia npm run dev:http

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

Flujo completo: get_pokemon, render_pokemon_widget e iframe en ChatGPT
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 npm run build:web?

Sprites/fuentes rotos en ChatGPT

CSP resourceDomains

Connector “raro” tras un cambio

Refresh del connector; ¿caché de URI?

Preview OK, ChatGPT KO

Host/iframe / túnel HTTPS, no el CSS

spawn / Node

Ruta absoluta de Node si usas nvm

Siguiente paso

Con esto cierras el circuito:

  1. Qué es un MCP

  2. Cómo crear un MCP

  3. App visual en ChatGPT (este)

Repo para clonar o forkear: github.com/omy13/mcp-ui-openai-pokemon.

Enlaces