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
Una app son siempre dos piezas en el mismo servidor:
- Una herramienta cuyo
_meta.ui.resourceUriapunta a un recursoui://. - Un recurso
ui://que devuelve el HTML con el tipo MIMEtext/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_metala 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 soportaio.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
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-inputse 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 enonAppCreated. - 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énlocalhosten desarrollo. Sin CSP declarada, el host aplicaconnect-src 'none'. connectDomainscubrefetchy WebSocket,resourceDomainscubre scripts, estilos, imágenes y fuentes, yframeDomainslos iframes anidados.- La CSP va en el elemento de
contentsque devuelve la lectura del recurso. Si la pones en la configuración deregisterAppResourceo 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: 100vhconautoResizecrea un bucle de crecimiento infinito. Usa unmin-heightfijo.- 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ú asendSizeChanged. - Algunos hosts han medido la altura del documento en lugar de respetar
size-changed(claude-ai-mcp#69). Mientras tanto, fijadocument.documentElement.style.heighttras pintar y sigue enviando el tamaño.
Qué ve el modelo
- Según la especificación,
structuredContentno entra en el contexto del modelo. En ChatGPT sí: solo_metaqueda oculto. Así que da siempre uncontentde texto, manténstructuredContentpequeñ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
updateModelContextpara contar lo que el usuario ve o ha cambiado, ysendMessagesolo 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é
localStoragepuede no existir en el iframe. Envuélvelo entry/catchy 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.authInfopasa aextra.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-hostoficial 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
- Repositorio y especificación: modelcontextprotocol/ext-apps
- Cambios de la especificación 2026-07-28
- Resumen de MCP Apps y hosts compatibles y el anuncio de enero de 2026
- Claude: resolución de problemas de MCP Apps
- OpenAI: MCP Apps en ChatGPT