Oct 2026

MCP Apps: interfaces dentro del chat y los problemas que te vas a encontrar

Una herramienta MCP normal devuelve texto. Una MCP App devuelve además una interfaz: un HTML que el host (Claude, ChatGPT, VS Code, Goose…) pinta dentro de la conversación, en un iframe aislado, y que puede volver a llamar a tus herramientas. Un selector de fechas, un mapa, un formulario o un panel con datos en vivo, sin salir del chat.

MCP Apps es la primera extensión oficial del protocolo (SEP-1865, id io.modelcontextprotocol/ui) y es estable desde el 26 de enero de 2026. Esta guía usa lo último a fecha de octubre de 2026:

Pieza Versión
Especificación MCP 2026-07-28
Extensión MCP Apps 2026-01-26 (estable)
@modelcontextprotocol/ext-apps 2.0.3
SDK de TypeScript (server, client, node, express) 2.x
zod 4.2 o superior

Cómo encaja todo

servidor MCP tool get-weather recurso ui://weather/app.html _meta.ui.resourceUri host (Claude, ChatGPT, VS Code…) modelo iframe aislado · vista CSP de _meta.ui.csp postMessage · JSON-RPC tools/call · resources/read
Una app es una herramienta más un recurso ui://. El host lee el HTML y lo pinta en un iframe aislado que habla con él por postMessage.

Una app son siempre dos piezas en el mismo servidor:

  1. Una herramienta cuyo _meta.ui.resourceUri apunta a un recurso ui://.
  2. Un recurso ui:// que devuelve el HTML con el tipo MIME text/html;profile=mcp-app.

Cuando el modelo llama a la herramienta, el host lee el recurso, monta el HTML en un iframe con su propio origen y una CSP estricta, y le pasa a la vista los argumentos y el resultado de la herramienta. A partir de ahí la vista puede llamar a herramientas del servidor, contarle al modelo lo que ve el usuario o enviar un mensaje al chat.

Lo que cambia con la especificación 2026-07-28

La revisión de julio de 2026 hace el protocolo sin estado:

  • Desaparece el saludo initialize. Cada petición lleva en _meta la versión del protocolo y las capacidades del cliente.
  • Desaparecen las sesiones (Mcp-Session-Id). Si necesitas estado entre llamadas, el servidor emite un identificador y lo recibe de vuelta como argumento.
  • Aparece server/discover, y el transporte HTTP+SSE queda obsoleto. Usa Streamable HTTP para servidores remotos y stdio para locales.
  • Las capacidades declaran extensions. Así anuncia el host que soporta io.modelcontextprotocol/ui.

Para una app esto se traduce en una regla: no guardes nada en memoria por sesión. Crea un servidor por petición y lleva el estado en identificadores que viajen en los argumentos.

Tutorial: una app del tiempo

Dependencias

Instala con npm en lugar de escribir versiones a mano, y no mezcles el SDK antiguo @modelcontextprotocol/sdk 1.x con ext-apps 2.x: no comparten clases ni tipos.

npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/server@^2 \
  @modelcontextprotocol/client@^2 @modelcontextprotocol/node@^2 \
  @modelcontextprotocol/express@^2 zod@^4.2 express cors
npm install -D typescript vite vite-plugin-singlefile tsx

El servidor

// server.ts
import fs from "node:fs/promises";
import { z } from "zod";
import { McpServer, type CallToolResult, type ReadResourceResult } from "@modelcontextprotocol/server";
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";

const resourceUri = "ui://weather/app.html";

// Sustituye por tu API real.
async function fetchWeather(city: string) {
  return { city, tempC: 23, sky: "despejado" };
}

export function createServer() {
  const server = new McpServer({ name: "weather", version: "1.0.0" });

  // 1. La herramienta que ve el modelo, enlazada a la interfaz.
  registerAppTool(
    server,
    "get-weather",
    {
      title: "El tiempo",
      description: "Muestra el tiempo de una ciudad.",
      inputSchema: z.object({ city: z.string() }),
      outputSchema: z.object({ city: z.string(), tempC: z.number(), sky: z.string() }),
      _meta: { ui: { resourceUri } },
    },
    async ({ city }): Promise<CallToolResult> => {
      const data = await fetchWeather(city);
      return {
        // Texto para el modelo y para hosts sin interfaz. Nunca lo omitas.
        content: [{ type: "text", text: `${city}: ${data.tempC} °C, ${data.sky}` }],
        // Datos para la vista.
        structuredContent: data,
      };
    },
  );

  // 2. Una herramienta solo para la interfaz: el modelo no la ve.
  registerAppTool(
    server,
    "refresh-weather",
    {
      description: "Refresca el tiempo desde la vista.",
      inputSchema: z.object({ city: z.string() }),
      _meta: { ui: { resourceUri, visibility: ["app"] } },
    },
    async ({ city }): Promise<CallToolResult> => {
      const data = await fetchWeather(city);
      return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
    },
  );

  // 3. El recurso con el HTML empaquetado en un solo fichero.
  registerAppResource(server, "Vista del tiempo", resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async (): Promise<ReadResourceResult> => ({
    contents: [
      {
        uri: resourceUri,
        mimeType: RESOURCE_MIME_TYPE,
        text: await fs.readFile("dist/app.html", "utf-8"),
        // La CSP va AQUÍ, en el elemento de contents, no en la configuración del recurso.
        _meta: {
          ui: {
            csp: { connectDomains: ["https://api.open-meteo.com"] },
            prefersBorder: false,
          },
        },
      },
    ],
  }));

  return server;
}

El transporte

Este es el patrón de los ejemplos oficiales de ext-apps 2.0.3: un servidor nuevo por petición, sin sesiones, y stdio como alternativa local.

// main.ts
import cors from "cors";
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./server.js";

if (process.argv.includes("--stdio")) {
  await createServer().connect(new StdioServerTransport());
} else {
  const app = createMcpExpressApp({ host: "0.0.0.0" });
  app.use(cors());
  app.all("/mcp", async (req, res) => {
    const server = createServer();
    const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    res.on("close", () => {
      transport.close();
      server.close();
    });
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
  });
  app.listen(3001);
}

El SDK 2.x también trae createMcpHandler, que habla la revisión 2026-07-28 de forma nativa y acepta clientes antiguos sin estado. Es el camino hacia delante; los ejemplos oficiales aún usan el patrón de arriba, que funciona igual.

La vista

// src/app.ts
import { App, applyDocumentTheme, applyHostStyleVariables, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "Weather", version: "1.0.0" });
const out = document.querySelector<HTMLElement>("#weather")!;
let city = "";

const render = (d?: { city: string; tempC: number; sky: string }) => {
  out.textContent = d ? `${d.city} · ${d.tempC} °C · ${d.sky}` : "Sin datos";
};

const applyContext = (ctx: McpUiHostContext) => {
  if (ctx.theme) applyDocumentTheme(ctx.theme);
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
};

// 1. Manejadores ANTES de conectar: tool-input llega una sola vez.
app.ontoolinput = ({ arguments: args }) => { city = String(args?.city ?? ""); };
app.ontoolresult = (result) => render(result.structuredContent as never);
app.onhostcontextchanged = applyContext;
app.onteardown = async () => ({});

// 2. Conectar.
await app.connect();
const ctx = app.getHostContext();
if (ctx) applyContext(ctx);

// 3. Interactuar.
document.querySelector("#refresh")!.addEventListener("click", async () => {
  const result = await app.callServerTool({ name: "refresh-weather", arguments: { city } });
  render(result.structuredContent as never);
  // Que el modelo sepa lo que está viendo el usuario.
  await app.updateModelContext({ content: [{ type: "text", text: `El usuario ve: ${out.textContent}` }] });
});

Con React, useApp de @modelcontextprotocol/ext-apps/react hace lo mismo. Los manejadores se registran en onAppCreated, que se ejecuta antes de conectar.

El empaquetado

El HTML llega al host como una cadena de texto, sin un servidor que sirva tus ficheros. Por eso todo tiene que ir dentro de un único HTML:

// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: { rollupOptions: { input: "app.html" }, outDir: "dist", emptyOutDir: false },
});

El ciclo de vida

vista host 1 · registrar ontoolinput / ontoolresult ui/initialize → ← hostContext (tema, tamaño, modo) ui/notifications/initialized → ← tool-input (una vez) ← tool-result callServerTool / updateModelContext →
El orden importa: la vista registra sus manejadores, se conecta y solo entonces recibe la entrada y el resultado de la herramienta.

Probarla

# Host de pruebas oficial, con paneles de entrada, resultado y contexto del modelo
git clone https://github.com/modelcontextprotocol/ext-apps && cd ext-apps && npm install
cd examples/basic-host && SERVERS='["http://localhost:3001/mcp"]' npm start   # http://localhost:8080

# Para probar en claude.ai, que no ve tu localhost
npx cloudflared tunnel --url http://localhost:3001

Problemas comunes

Son los que más se repiten en la documentación de Claude y ChatGPT y en las incidencias abiertas en GitHub.

Se ve la llamada pero no la app

  • No hay app.connect(), o la raíz queda con altura 0. Hasta conectar no llega ningún evento.
  • Manejadores registrados después de conectar. tool-input se envía una sola vez; si llegas tarde, te lo pierdes (ext-apps#476). Desde la 1.7 hay un aviso, y { strict: true } lo convierte en error. En React, regístralos en onAppCreated.
  • Recursos que no cargan. Sin vite-plugin-singlefile, los <script src> relativos dan 404 porque no hay nadie sirviéndolos.

La CSP lo bloquea todo en silencio

  • Toda petición de red necesita su dominio en _meta.ui.csp, también localhost en desarrollo. Sin CSP declarada, el host aplica connect-src 'none'.
  • connectDomains cubre fetch y WebSocket, resourceDomains cubre scripts, estilos, imágenes y fuentes, y frameDomains los iframes anidados.
  • La CSP va en el elemento de contents que devuelve la lectura del recurso. Si la pones en la configuración de registerAppResource o en el resultado de la herramienta, se ignora sin avisar.

CORS, aunque la CSP esté bien

El iframe tiene un origen propio y opaco. Si tu API filtra por origen, declara _meta.ui.domain. En Claude el dominio es sha256(url del conector) truncado a 32 caracteres hexadecimales y seguido de .claudemcpcontent.com; el hash usa la URL exacta, y la barra final cuenta. Los conectores stdio no pueden usarlo. En iOS, WebKit no envía Referer entre orígenes: filtra por Origin.

Altura y tamaño

  • height: 100vh con autoResize crea un bucle de crecimiento infinito. Usa un min-height fijo.
  • La altura se queda atascada al salir de pantalla completa (ext-apps#502). Desde la 1.7 puedes desactivar el redimensionado automático con useApp({ autoResize: false }) y llamar tú a sendSizeChanged.
  • Algunos hosts han medido la altura del documento en lugar de respetar size-changed (claude-ai-mcp#69). Mientras tanto, fija document.documentElement.style.height tras pintar y sigue enviando el tamaño.

Qué ve el modelo

  • Según la especificación, structuredContent no entra en el contexto del modelo. En ChatGPT sí: solo _meta queda oculto. Así que da siempre un content de texto, mantén structuredContent pequeño y mueve lo grande o sensible a _meta.
  • Las herramientas de apoyo de la interfaz llevan visibility: ["app"]. Si una herramienta no incluye "app", el host rechaza que la vista la llame.
  • Usa updateModelContext para contar lo que el usuario ve o ha cambiado, y sendMessage solo cuando quieras que el modelo responda.

Resultados grandes

En claude.ai, con la ejecución de código activa, un resultado de más de unos 150.000 caracteres se guarda en un fichero y la app recibe solo una referencia. Claude Code corta en 25.000 tokens por defecto. Pagina, o deja que la vista pida los datos con herramientas ["app"].

Varias copias vivas

Cada llamada monta un iframe nuevo, y los anteriores siguen vivos mandando contexto. Claude documenta cómo retirarlos: el servidor emite { createdAt, seq } y las copias se coordinan por BroadcastChannel. En ChatGPT, llamadas seguidas desmontan y vuelven a montar la vista; diseña un pintado idempotente que se recupere desde el resultado.

Estado y caché

  • localStorage puede no existir en el iframe. Envuélvelo en try/catch y guarda lo importante en el servidor.
  • Los hosts cachean el recurso por URI. Tras un cambio incompatible, publica una URI nueva (ui://weather/app-v2.html).

Transporte, versiones y autenticación

  • Un host remoto no ve tu localhost: usa un túnel.
  • En stdio, nunca escribas logs en stdout: rompes el protocolo.
  • ext-apps 2.x necesita el SDK 2.x y zod 4.2 o superior. Con zod 4.0 o 4.1 falla la generación de esquemas.
  • En el SDK 2.x, extra.authInfo pasa a extra.http?.authInfo.

Depurar

  • Usa app.sendLog() para ver los logs de la vista en el host.
  • En Claude Desktop: Ayuda → Solución de problemas → activar el modo desarrollador, y abre las herramientas con Cmd+Opt+I. Tu app es el iframe interior. En iOS, usa el inspector web de Safari.
  • El basic-host oficial enseña la entrada, el resultado, los mensajes y el contexto del modelo en paneles separados.

Tema

No pongas colores fijos. Usa las variables del host con un valor de respaldo, por ejemplo var(--color-background-primary, #fff), aplica applyDocumentTheme y respeta safeAreaInsets.

Fuentes