Tutoriales
Cómo añadir una interfaz interactiva a tu servidor MCP
Tu servidor MCP ya tiene tools. Con MCP Apps, una de ellas puede responder con una tabla, un gráfico o un tablero que se puede tocar, en ChatGPT, Claude, VS Code y otros hosts. Aquí tienes las tres formas de hacerlo y, línea a línea, la más rápida.
8 min de lectura
¿Qué facturas siguen sin cobrar este mes?
Ha usado show_table
#La respuesta corta
Para añadir una interfaz a un servidor MCP registras dos cosas con la extensión MCP Apps: un recurso con una URI ui:// que devuelve un documento HTML, y una tool cuyo _meta.ui.resourceUri apunta a ese recurso. Cuando un host compatible con MCP Apps llama a la tool, carga el HTML en un iframe aislado y le pasa los datos de la tool. Los hosts que no pintan interfaces siguen usando el texto que devuelve la tool, así que para ellos no se rompe nada (especificación de MCP Apps).
La tabla de arriba es una interfaz de ese tipo. En una conversación, el modelo llama a una tool show_* con las columnas y las filas como argumentos, y el host pinta esta vista con ellas.
Lo que cambia de un enfoque a otro es quién escribe el HTML y el código que habla con el host. Hay tres maneras.
#Tres formas de añadir una interfaz a un servidor MCP
| Enfoque | Qué escribes tú | Para qué va bien |
|---|---|---|
A mano con @modelcontextprotocol/ext-apps | La vista (HTML, JS, CSS), el empaquetado y el registro | Control total, interacciones a medida, sin dependencias extra |
| Un framework (mcp-use, Skybridge, sunpeak, FastMCP...) | Componentes según el modelo del framework | Una aplicación entera construida alrededor de la interfaz, con emuladores locales |
| Diseñar el widget en Widgetry y enlazarlo | Unas pocas líneas de registro | Enseñar datos (tablas, gráficos, tableros, KPI) sin montar un front |
A mano. El SDK oficial, @modelcontextprotocol/ext-apps, trae registerAppTool y registerAppResource para el servidor y la clase App para la vista. Tú escribes la vista, la empaquetas en un solo HTML y la sirves como recurso. Es el camino más flexible y el que más trabajo lleva. El tutorial Crea una MCP App en TypeScript lo hace desde cero.
Con un framework. Varios proyectos envuelven el SDK en una experiencia de desarrollo más amplia. mcp-use registra componentes de React como widgets y trae un inspector. Skybridge es un framework full-stack de TypeScript y React con emulador. sunpeak incluye simuladores locales de ChatGPT y Claude para probar. FastMCP tiene una vista previa de apps para servidores en Python. Elige uno si la interfaz es el centro de tu producto y quieres sus convenciones.
Diseñarlo y enlazarlo. Si lo que necesitas es enseñar datos que el modelo ya tiene (una tabla de oportunidades, un gráfico de ingresos, el tablero de un proyecto), puedes diseñar el widget en Widgetry a partir de una plantilla, publicarlo y registrarlo en tu servidor con una función que lo lee de su URL en vivo. Tu servidor conserva sus tools y sus datos; el widget es lo único que viene de fuera. El resto de la guía recorre ese camino y, después, la variante sin enlace en vivo.
#Paso 1: elige una plantilla
Entra en Widgetry y crea un widget desde una de las plantillas: tabla, gráfico de barras, métricas, kanban, donut y más. Cada plantilla trae datos de ejemplo, así que ves un widget funcionando desde el primer momento. Elige un diseño (hay veinte, y puedes crear los tuyos); cualquier tipo de widget funciona con cualquier diseño.
En esta guía usamos un widget llamado Sales board, hecho con la plantilla Tabla, con el slug sales-board, en una organización con el slug acme.
#Paso 2: define el esquema de datos y los datos de ejemplo
Dos partes del widget deciden cómo será la tool de tu servidor:
- El esquema de datos (pestaña Esquema) es un JSON Schema con raíz
type: "object". Se convierte en el esquema de entrada de la tool, así que es lo que el modelo tiene que mandar. En la plantilla Tabla pide untitle, una lista decolumns(cada una conkey,labely unalignopcional) y una lista derows. - Los datos de ejemplo (pestaña Datos) son con los que el editor pinta la vista previa. Los de un widget publicado tienen que cumplir su esquema.
Después, en Ajustes, escribe la Descripción para el agente. El modelo la lee para decidir cuándo llamar a la tool: di qué enseña el widget y para qué datos sirve. Sin ella no se puede publicar.
#Paso 3: pruébalo en un host de verdad
La vista previa del editor no es una maqueta. Es un host de MCP Apps: carga el mismo documento que servirá tu servidor y habla el mismo protocolo que los hosts de chat, en claro y en oscuro. Si el widget carga imágenes de otro sitio, declara sus orígenes en Dominios de las imágenes: los hosts bloquean cualquier origen que el widget no declare en su CSP, y la vista previa bloquea los mismos (lo explica la CSP de MCP Apps).
#Paso 4: publica el widget
Pulsa Publicar. Al publicar se comprueba que el esquema es válido, que los datos de ejemplo lo cumplen y que hay descripción. Solo se puede enlazar un widget publicado y, desde ese momento, Widgetry rechaza cualquier edición que rompa su esquema o sus datos, así que ningún cambio rompe la tool de tu servidor.
#Paso 5: crea una clave de API de lectura
En Ajustes, Claves de API, crea una clave con permiso de Lectura. Empieza por wgt_ y solo se enseña una vez. Ponla en tu servidor como la variable de entorno WIDGETRY_API_KEY. Para el enlace en vivo basta una clave de lectura; las de escritura son para agentes que crean widgets (lo cuenta crear widgets de MCP Apps con un agente).
#Paso 6: registra el widget en tu servidor
Instala los paquetes v2 del SDK de MCP para TypeScript y el SDK de MCP Apps:
npm install @modelcontextprotocol/server@^2 @modelcontextprotocol/client@^2 @modelcontextprotocol/ext-apps zodY añade el código que enseña el editor en Llévatelo a tu servidor MCP. Para el Sales board es este fichero:
import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server'
import { fromJsonSchema, type McpServer } from '@modelcontextprotocol/server'
const WIDGET_URL = 'https://widgets.gonzaloverdugo.com/api/v1/organizations/acme/widgets/sales-board/live'
type LinkedWidget = { manifest: { tool: any; resource: any }; document: string }
let cached: { at: number; widget: Promise<LinkedWidget> } | undefined
/** The published widget, read from Widgetry with a read-only key (WIDGETRY_API_KEY) and kept for a minute. */
function linkedWidget(): Promise<LinkedWidget> {
if (!cached || Date.now() - cached.at > 60_000) {
const widget = fetch(WIDGET_URL, { headers: { authorization: `Bearer ${process.env.WIDGETRY_API_KEY}` } }).then(async (response) => {
if (!response.ok) throw new Error(`Widgetry answered ${response.status} for ${WIDGET_URL}`)
return (await response.json()).data as LinkedWidget
})
cached = { at: Date.now(), widget }
widget.catch(() => (cached = undefined))
}
return cached.widget
}
/** Adds the widget to your server: the tool that shows it and its ui:// resource, both read from Widgetry. */
export async function registerSalesBoard(server: McpServer) {
const { tool, resource } = (await linkedWidget()).manifest
registerAppResource(server, tool.title, resource.uri, { description: tool.description, _meta: resource._meta }, async () => {
const { document, manifest } = await linkedWidget()
return { contents: [{ uri: resource.uri, mimeType: RESOURCE_MIME_TYPE, text: document, _meta: manifest.resource._meta }] }
})
registerAppTool(
server,
tool.name,
{
title: tool.title,
description: tool.description,
inputSchema: fromJsonSchema(tool.inputSchema),
annotations: tool.annotations,
_meta: tool._meta,
},
async (data) => ({
content: [{ type: 'text', text: `Showing ${tool.title}` }],
structuredContent: data as Record<string, unknown>,
}),
)
}Llámala donde creas el servidor, junto a tus propias tools:
import { McpServer } from '@modelcontextprotocol/server'
import { registerSalesBoard } from './sales-board.js'
const server = new McpServer({ name: 'acme-crm', version: '1.0.0' })
await registerSalesBoard(server)
// Your other tools, then connect the transport as you already do.#Qué hace este código
linkedWidget()pideWIDGET_URLcon la clave como token Bearer. La respuesta trae el manifiesto del widget (la definición de la tool y el registro del recurso, con su CSP) y el documento del widget (el HTML autocontenido). El resultado se guarda 60 segundos; si la petición falla, se vacía la caché y la siguiente llamada lo reintenta.registerAppResourceregistra el recursoui://. Su función de lectura vuelve a llamar alinkedWidget(), así que cada vez que un host lee el recurso recibe el documento tal como está publicado, con un minuto de retraso como mucho.registerAppToolregistra la tool con el nombre, el título, la descripción y el esquema de entrada del manifiesto.fromJsonSchemaconvierte el JSON Schema del widget en el tipo de esquema que espera el SDK. La función de la tool no hace nada por su cuenta: devuelve comostructuredContentlos argumentos que mandó el modelo, que es lo que pinta el widget, y un texto corto.
#Qué pasa cuando cambias el widget
Edita el widget publicado en Widgetry y guarda: la URL en vivo sirve la versión nueva y tu servidor la recoge en menos de un minuto, sin desplegar. La definición de la tool se lee una vez, cuando corre registerSalesBoard. Un servidor que crea un McpServer nuevo en cada petición (sin estado) recibe un cambio de esquema en la siguiente petición; uno de larga duración, al reiniciarse. El documento siempre sigue a la caché.
#La alternativa: descarga el widget y quédatelo
Si prefieres no depender de Widgetry en tiempo de ejecución, cambia el panel de exportación a Descargar los ficheros. Te llevas sales-board.html (el documento del widget) y sales-board.widget.json (el manifiesto). Guárdalos junto al código de tu servidor y regístralos con este snippet, que lee los dos ficheros del disco:
import { readFileSync } from 'node:fs'
import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server'
import { fromJsonSchema, type McpServer } from '@modelcontextprotocol/server'
const { tool, resource } = JSON.parse(readFileSync(new URL('./sales-board.widget.json', import.meta.url), 'utf8'))
const html = readFileSync(new URL('./sales-board.html', import.meta.url), 'utf8')
/** Adds the widget to your server: one ui:// resource and the tool that shows it. */
export function registerSalesBoard(server: McpServer) {
registerAppResource(server, tool.title, resource.uri, { description: tool.description, _meta: resource._meta }, async () => ({
contents: [{ uri: resource.uri, mimeType: RESOURCE_MIME_TYPE, text: html, _meta: resource._meta }],
}))
registerAppTool(
server,
tool.name,
{
title: tool.title,
description: tool.description,
inputSchema: fromJsonSchema(tool.inputSchema),
annotations: tool.annotations,
_meta: tool._meta,
},
async (data) => ({
content: [{ type: 'text', text: `Showing ${tool.title}` }],
structuredContent: data as Record<string, unknown>,
}),
)
}El registro es el mismo; solo cambia de dónde sale. El precio es el evidente: los ficheros no cambian hasta que vuelvas a exportar, y exportar no exige publicar.
#Qué ve el modelo
Elijas el camino que elijas, el host ve una tool nueva. Para el Sales board, en tools/list queda así (con el esquema recortado):
{
"name": "show_sales_board",
"title": "Sales board",
"description": "Shows open deals as a table: the caller defines the columns and passes one object per row.",
"inputSchema": {
"type": "object",
"required": ["title", "columns", "rows"],
"properties": { "title": { "type": "string" }, "columns": { "type": "array" }, "rows": { "type": "array" } }
},
"annotations": { "readOnlyHint": true, "openWorldHint": false },
"_meta": { "ui": { "resourceUri": "ui://widgets/sales-board.html" } }
}- El nombre es
show_más el slug, con los guiones cambiados por guiones bajos. - La descripción es la que escribiste para el agente. Es la señal principal con la que el modelo elige la tool, así que merece una frase pensada.
- El esquema de entrada es el esquema de datos del widget. El modelo lo rellena con lo que sacó de tus otras tools o de la conversación, y después llama a la tool del widget. El widget nunca va a buscar datos por su cuenta.
- Las annotations dicen que la tool solo lee y no toca nada fuera.
_meta.ui.resourceUriune la tool con el documento del widget.registerAppToolescribe además la clave plana antiguaui/resourceUripara los hosts que todavía la leen.
#Qué hacen los hosts que no pintan interfaces
MCP Apps degrada a propósito: un host sin soporte para la extensión trata la tool como una tool normal (especificación). Los dos snippets devuelven un texto en content (Showing Sales board) junto a structuredContent, así que un host de solo texto sigue recibiendo una respuesta y los datos. Claude Code en la terminal es un ejemplo: llama a la tool como texto y no pinta la interfaz (quickstart de Claude). La guía de clientes de MCP Apps dice qué hosts pintan widgets.
#Cómo probarlo en Claude o en ChatGPT
Claude y ChatGPT se conectan a tu servidor desde su propia infraestructura, así que localhost no les vale: dale al servidor una URL HTTPS pública o usa un túnel mientras iteras.
- Claude: añade el servidor por URL como conector personalizado (cómo hacerlo), actívalo en un chat y pide algo que enseñe el widget. Si la tool se ejecuta pero no aparece la interfaz, la página de resolución de problemas de MCP Apps de Anthropic dice qué mirar.
- ChatGPT: activa el Developer mode en Settings, Security and login y añade la URL del servidor (con la ruta
/mcp) desde la página de plugins. Cuando cambies una tool, pulsa Refresh en la conexión y empieza una conversación nueva (documentación de OpenAI).
Basta un prompt tan simple como "Enséñame en una tabla las oportunidades abiertas de más de 5.000 euros": el modelo reúne las filas con tus tools y llama a show_sales_board con ellas.
¿Todavía no tienes servidor? Publica el widget y conecta a tu agente el endpoint MCP de Widgetry, https://widgets.gonzaloverdugo.com/mcp: las apps de chat entran con OAuth y los agentes de código, con una clave de API. Sirve una tool show_* por cada widget publicado de tu organización, y es una forma rápida de ver un widget en una conversación real antes de escribir código de servidor.