Tutoriales
Crea una MCP App en TypeScript
Un tutorial completo de MCP Apps con el SDK oficial, @modelcontextprotocol/ext-apps: una tool unida a un recurso ui://, una vista que habla con el host mediante la clase App, el build en un solo fichero y qué versión del SDK elegir ahora que ha salido la v2.
8 min de lectura
#Qué vas a construir
En este tutorial de MCP Apps vas a montar un servidor MCP pequeño en TypeScript con una tool, show_checklist, que en lugar de texto responde con una checklist de release interactiva. El usuario marca tareas dentro de la conversación, y un botón le pide al modelo un resumen. Por el camino usas todas las piezas del SDK oficial, @modelcontextprotocol/ext-apps: registerAppTool y registerAppResource en el servidor, y la clase App en la vista.
El código usa ext-apps 2.0.3 con los paquetes v2 del SDK de MCP para TypeScript y zod 4.2, a octubre de 2026. Si tu servidor sigue con el SDK v1, lee antes ext-apps v1 o v2: las diferencias son pocas y están ahí.
Una MCP App tiene tres partes:
- Una tool que llama el modelo, con
_meta.ui.resourceUriapuntando a un recurso de interfaz. - Un recurso con una URI
ui://y el tipo MIMEtext/html;profile=mcp-app, cuyo contenido es un documento HTML. - La vista: ese documento HTML, que corre en un iframe aislado dentro del host y habla con él por JSON-RPC sobre
postMessage.
Si prefieres empezar por los conceptos, lee qué son las MCP Apps.
#ext-apps v1 o v2: cuál elegir
@modelcontextprotocol/ext-apps 2.0.0 salió el 8 de septiembre de 2026, y la última versión es la 2.0.3. El cambio grande está debajo: la 2.x depende de los paquetes separados del SDK v2 de MCP para TypeScript (@modelcontextprotocol/server, @modelcontextprotocol/client, @modelcontextprotocol/core, más @modelcontextprotocol/node y @modelcontextprotocol/express para HTTP) en lugar de @modelcontextprotocol/sdk 1.x, y exige zod@^4.2.0 (guía de migración).
Lo que no cambia es el protocolo. El README lo dice claro: una vista 2.x funciona en un host 1.x, y un host 2.x pinta vistas 1.x. La versión que elijas no decide qué hosts pintan tu app.
Hay una fuente de confusión: el propio quickstart de MCP Apps de Claude sigue instalando @modelcontextprotocol/ext-apps@^1 (verificado con la 1.7.5), porque parte de un servidor que usa @modelcontextprotocol/sdk 1.x. Los dos están bien para su punto de partida.
Cuál elegir:
- Servidor nuevo: v2. Es lo que instalan el quickstart oficial y el README, es la línea que recibe el trabajo nuevo y te ahorras una migración.
- Servidor existente con
@modelcontextprotocol/sdk1.x: quédate en ext-apps^1hasta migrar el SDK del servidor. ext-apps 2.x tiene los paquetes v2 como peers, así que mezclarla con el SDK 1.x es justo lo que no funciona.
Las diferencias de código que vas a encontrar en este tutorial:
v1 (ext-apps@^1) | v2 (ext-apps@^2) | |
|---|---|---|
Import de McpServer | @modelcontextprotocol/sdk/server/mcp.js | @modelcontextprotocol/server |
| Transporte stdio | @modelcontextprotocol/sdk/server/stdio.js | @modelcontextprotocol/server/stdio |
inputSchema | un shape de zod { release: z.string() } | z.object({ release: z.string() }) (el shape suelto sigue funcionando, pero está obsoleto) |
| zod | 3 o 4 | solo ^4.2.0 |
registerAppTool, registerAppResource, App | mismos nombres y argumentos | mismos nombres y argumentos |
#Prepara el proyecto
Crea una carpeta e instala los paquetes. Para un servidor, el README pide ext-apps con los paquetes de cliente y servidor y zod:
mkdir release-checklist && cd release-checklist
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0
npm install -D typescript tsx vite vite-plugin-singlefile @types/nodeSi vas a servir por HTTP con Express, añade también @modelcontextprotocol/node@^2.0.0 y @modelcontextprotocol/express@^2.0.0.
#El servidor: registerAppTool y registerAppResource
registerAppTool y registerAppResource vienen de @modelcontextprotocol/ext-apps/server. Envuelven el registerTool y el registerResource del SDK, rellenan los metadatos de interfaz (también la clave plana antigua _meta["ui/resourceUri"] para hosts viejos) y ponen por defecto el tipo RESOURCE_MIME_TYPE, que es text/html;profile=mcp-app.
// server.ts
import { readFile } from 'node:fs/promises'
import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server'
import { McpServer } from '@modelcontextprotocol/server'
import { z } from 'zod'
const VIEW_URI = 'ui://checklist/view.html'
type Item = { id: string; label: string; done: boolean }
// In memory, for the tutorial. A real server reads this from its own data.
const items: Item[] = [
{ id: 'notes', label: 'Write the release notes', done: true },
{ id: 'qa', label: 'QA sign-off', done: false },
{ id: 'deploy', label: 'Deploy to production', done: false },
]
function checklist(release: string) {
const open = items.filter((item) => !item.done).length
return {
// Text-only hosts and the model read this.
content: [{ type: 'text' as const, text: `Release ${release}: ${open} of ${items.length} items still open.` }],
// The view reads this.
structuredContent: { release, items },
}
}
export function createServer(): McpServer {
const server = new McpServer({ name: 'release-checklist', version: '1.0.0' })
registerAppTool(
server,
'show_checklist',
{
title: 'Release checklist',
description: 'Shows the checklist of a release so the user can review it and tick items off.',
inputSchema: z.object({ release: z.string().describe('Release name, such as v2.4') }),
_meta: { ui: { resourceUri: VIEW_URI } },
},
async ({ release }) => checklist(release),
)
// Called by the view, not by the model: visibility ["app"] hides it from the model.
registerAppTool(
server,
'toggle_item',
{
description: 'Marks a checklist item as done or not done.',
inputSchema: z.object({ release: z.string(), id: z.string() }),
_meta: { ui: { resourceUri: VIEW_URI, visibility: ['app'] } },
},
async ({ release, id }) => {
const item = items.find((entry) => entry.id === id)
if (item) item.done = !item.done
return checklist(release)
},
)
registerAppResource(server, 'Release checklist view', VIEW_URI, { description: 'Interactive release checklist' }, async () => ({
contents: [
{
uri: VIEW_URI,
mimeType: RESOURCE_MIME_TYPE,
text: await readFile(new URL('./dist/view.html', import.meta.url), 'utf8'),
},
],
}))
return server
}Tres detalles importan aquí:
contentystructuredContentjuntos. La vista pinta a partir destructuredContent. El texto decontentes lo que enseña un host sin soporte de MCP Apps y lo que lee el modelo. Claude Code, por ejemplo, llama a la tool como texto y no pinta la interfaz (quickstart de Claude). No lo dejes nunca vacío.visibility: ['app']sacatoggle_itemde la lista de tools del modelo, pero la vista puede seguir llamándola. Por defecto es["model", "app"].- La URI del recurso tiene que ser la misma cadena en el
_metade la tool y enregisterAppResource.
El punto de entrada conecta el servidor a un transporte. Para probar en local con Claude Desktop, lo más corto es stdio:
// main.ts
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
import { createServer } from './server.js'
await createServer().connect(new StdioServerTransport())Claude en la web y ChatGPT se conectan a servidores remotos por HTTP. El quickstart oficial trae un main.ts completo que sirve el mismo createServer() por Streamable HTTP con @modelcontextprotocol/express y @modelcontextprotocol/node, con un servidor nuevo por petición.
#La vista: la clase App
La vista es HTML normal más un script de módulo. Primero el marcado:
<!-- view.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<title>Release checklist</title>
</head>
<body>
<h1 id="title">Waiting for the release...</h1>
<ul id="items"></ul>
<button id="summary" type="button">Ask for a summary</button>
<a id="process" href="https://example.com/release-process">Release process</a>
<script type="module" src="/src/view.ts"></script>
</body>
</html>Después el script. App sale de la raíz del paquete:
// src/view.ts
import { App, applyDocumentTheme, applyHostStyleVariables } from '@modelcontextprotocol/ext-apps'
type Item = { id: string; label: string; done: boolean }
type Checklist = { release: string; items: Item[] }
const app = new App({ name: 'Release checklist', version: '1.0.0' })
const $ = (id: string) => document.getElementById(id)!
let release = ''
function render(data: Checklist) {
release = data.release
$('title').textContent = `Release ${data.release}`
$('items').replaceChildren(
...data.items.map((item) => {
const box = Object.assign(document.createElement('input'), { type: 'checkbox', checked: item.done })
box.addEventListener('change', async () => {
// A server tool, called through the host.
const result = await app.callServerTool({ name: 'toggle_item', arguments: { release, id: item.id } })
render(result.structuredContent as Checklist)
})
const label = document.createElement('label')
label.append(box, ` ${item.label}`)
const row = document.createElement('li')
row.append(label)
return row
}),
)
}
function applyHostContext() {
const context = app.getHostContext()
if (context?.theme) applyDocumentTheme(context.theme)
if (context?.styles?.variables) applyHostStyleVariables(context.styles.variables)
}
// 1. Handlers first, so the first notifications are not missed.
app.ontoolinput = ({ arguments: args }) => {
$('title').textContent = `Loading release ${String(args?.release ?? '')}...`
}
app.ontoolresult = (result) => {
if (result.structuredContent) render(result.structuredContent as Checklist)
}
app.onhostcontextchanged = applyHostContext
$('summary').addEventListener('click', () => {
app.sendMessage({ role: 'user', content: [{ type: 'text', text: `Summarize what is still open for release ${release}.` }] })
})
$('process').addEventListener('click', (event) => {
event.preventDefault()
app.openLink({ url: 'https://example.com/release-process' })
})
// 2. Then connect: this runs the ui/initialize handshake with the host.
app.connect().then(applyHostContext)Qué hace cada llamada:
new App({ name, version })crea el lado de la vista del protocolo.- Handlers antes de
connect(). El host puede mandar la entrada y el resultado de la tool justo después del saludo inicial. Un handler que llega tarde se los puede perder, y la vista se queda en su estado de carga. El quickstart de Claude y la documentación del SDK insisten en este orden. ontoolinputrecibe los argumentos completos que mandó el modelo, antes de que llegue el resultado. Úsalo para un estado de carga. (ontoolinputpartiallos recibe mientras llegan por streaming.)ontoolresultrecibe el resultado de la tool:contentystructuredContent. Aquí es donde pintas.callServerTool({ name, arguments })llama a una tool de tu servidor a través del host y devuelve su resultado. Así la vista pide datos nuevos o cambia algo sin otro turno del modelo.sendMessagepublica un mensaje en la conversación como si fuera del usuario, y el modelo lo contesta. Sirve para botones de "pregunta por esto".openLink({ url })pide al host que abra una URL. El host decide cómo abrirla y puede negarse; entonces el resultado traeisError: true. Úsalo en lugar de dejar que el iframe navegue.getHostContext()devuelve lo que el host contó de sí mismo al iniciar:theme, variables CSS enstyles.variables,displayMode,locale,timeZoney más.applyDocumentThemeyapplyHostStyleVariablesaplican el tema y las variables del host al documento, yonhostcontextchangedsalta cuando cambian (por ejemplo, si el usuario pasa a modo oscuro). La guía de temas lo cuenta con más detalle.
Una nota sobre la v2: en la 2.x los setters on* están marcados como obsoletos en favor de app.addEventListener('toolresult', handler), que permite varios listeners y quitarlos. Los setters siguen funcionando y son los que usan los dos quickstarts, así que el código de arriba corre igual en 1.x y en 2.x.
#Empaqueta la vista en un solo HTML
El recurso devuelve un único documento HTML, y el host lo carga en un sandbox con una CSP estricta. Por defecto la vista solo puede ejecutar scripts en línea o de su propio origen, y no puede conectarse a ningún sitio (la CSP de MCP Apps). Lo más sencillo para cumplirla es meterlo todo dentro: JavaScript, CSS y el propio SDK. El quickstart oficial lo hace con Vite y vite-plugin-singlefile:
// vite.config.ts
import { defineConfig } from 'vite'
import { viteSingleFile } from 'vite-plugin-singlefile'
export default defineConfig({
plugins: [viteSingleFile()],
build: { outDir: 'dist', rollupOptions: { input: 'view.html' } },
})npx vite build genera dist/view.html con el script dentro, que es el fichero que lee server.ts. Lo mismo vale con React, Vue, Svelte, Preact o Solid: el repo del SDK tiene una plantilla de inicio para cada uno, y @modelcontextprotocol/ext-apps/react añade hooks como useApp y useHostStyles.
#O sin build: carga App desde un CDN
Para una vista pequeña puedes prescindir del bundler, como hace el quickstart de Claude. Escribes el HTML como cadena en el servidor, importas un build de App desde un CDN y declaras ese CDN en la CSP del recurso para que el sandbox deje cargar el script:
const VIEW_HTML = `<!doctype html>
<html><head><meta charset="utf-8"><meta name="color-scheme" content="light dark"></head>
<body>
<h1 id="title">Waiting for the release...</h1>
<script type="module">
import { App } from 'https://unpkg.com/@modelcontextprotocol/ext-apps@2.0.3/dist/src/app-with-deps.js'
const app = new App({ name: 'Release checklist', version: '1.0.0' })
app.ontoolresult = ({ structuredContent }) => {
document.getElementById('title').textContent = 'Release ' + structuredContent.release
}
await app.connect()
</script>
</body></html>`
registerAppResource(server, 'Release checklist view', VIEW_URI, { description: 'Interactive release checklist' }, async () => ({
contents: [
{
uri: VIEW_URI,
mimeType: RESOURCE_MIME_TYPE,
text: VIEW_HTML,
_meta: { ui: { csp: { resourceDomains: ['https://unpkg.com'] } } },
},
],
}))app-with-deps.js es el build de App con sus dependencias incluidas, así que funciona sin import map. Fija en la URL la misma versión que instalaste (el quickstart de Claude usa la 1.7.5 con ext-apps v1). Para producción, Anthropic recomienda servir el script desde tu propio origen o empaquetarlo, y declarar solo ese origen en resourceDomains.
#Ejecútalo y pruébalo
Construye la vista y apunta un host de MCP Apps al servidor. Para Claude Desktop por stdio, añade el servidor a su fichero de configuración:
{
"mcpServers": {
"release-checklist": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/release-checklist/main.ts"]
}
}
}Reinicia Claude Desktop, activa el servidor en un chat y pide "Enséñame la checklist de la release v2.4". El modelo llama a show_checklist, aparece la vista, y al marcar una tarea se llama a toggle_item a través del host.
Para Claude en la web o ChatGPT, sirve por HTTP en una URL HTTPS pública (mientras iteras, un túnel vale) y añádelo como conector personalizado en Claude, o con el modo desarrollador en ChatGPT (pasos de OpenAI). La guía añadir una interfaz a tu servidor MCP explica los dos procedimientos, y la de clientes de MCP Apps dice qué hosts pintan vistas.
Si la tool se ejecuta pero la vista sale en blanco, casi siempre es una de tres cosas: un handler registrado después de connect(), un script o una imagen de un origen que falta en la CSP, o una URI de recurso que no coincide con el _meta.ui.resourceUri de la tool.
#Sáltate el código repetitivo
Todo lo anterior es el camino correcto cuando la vista es tu producto: un editor a medida, un juego, una interacción que nadie ha hecho. Muchas MCP Apps son más sencillas. Enseñan datos que el modelo ya tiene: una tabla, un gráfico, un tablero, unos cuantos KPI. Para esas, escribir la vista, el manejo del tema, la CSP y el build es trabajo que repites cada vez.
Para eso está Widgetry. Partes de una de las trece plantillas (como tabla, gráfico de área o kanban), ajustas el esquema de datos, los datos de ejemplo y el diseño en un editor cuya vista previa es un host de MCP Apps de verdad, y publicas. Tu servidor lo registra con registerAppTool y registerAppResource, igual que en este tutorial, leyendo el widget de su URL en vivo con una clave de lectura, así que lo que publiques le llega sin desplegar. Si prefieres quedarte con los ficheros, descargas el documento HTML y el manifiesto. La guía añadir una interfaz a tu servidor MCP enseña ese código línea a línea, y MCP Apps frente a MCP-UI y Apps SDK explica cómo encaja este estándar con las opciones anteriores.