MCP Apps
Traducción automática
Esta página se tradujo automáticamente a partir de la documentación en inglés, y la página en inglés es la versión de referencia. Si algo no se lee bien, Traducciones explica cómo avisarnos.
Una MCP App es una herramienta con cara visible: además de sus datos, la herramienta apunta a un documento HTML que el host muestra como una superficie interactiva.
Dos partes, siempre dos partes:
- Una herramienta que hace el trabajo y devuelve datos, como cualquier otra herramienta.
- Un recurso
ui://que contiene el HTML que el host muestra para ella.
La herramienta lleva una referencia _meta.ui.resourceUri al recurso. El host lo obtiene
con resources/read, lo muestra en un iframe aislado (sandboxed) y envía el resultado de la
herramienta a ese iframe mediante postMessage. El servidor nunca envía ni recibe
mensajes ui/*: ese tráfico ocurre entre el host y el iframe. Tú sirves una herramienta
y un documento HTML; el host monta el espectáculo.
El SDK incluye esto como la extensión integrada Apps (io.modelcontextprotocol/ui).
Si las extensiones son nuevas para ti, échale un vistazo primero a esa página. Un minuto,
y luego vuelve.
Un reloj con cara visible
from mcp import Client
from mcp.client import advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context
CLOCK_HTML = """\
<!doctype html>
<title>Clock</title>
<h1 id="now">...</h1>
<script>
window.addEventListener("message", (event) => {
const text = event.data?.result?.content?.[0]?.text;
if (text) document.getElementById("now").textContent = text;
});
</script>
"""
apps = Apps()
@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
def get_time(ctx: Context) -> str:
now = "2026-06-26T12:00:00Z"
if not client_supports_apps(ctx):
return f"The time is {now}."
return now
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
mcp = MCPServer("clock", extensions=[apps])
async def main() -> None:
async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client:
result = await client.call_tool("get_time", {})
print(result.content)
# [TextContent(text='2026-06-26T12:00:00Z')]
Cuatro pasos:
Apps(): una sola instancia contiene tus herramientas vinculadas a una UI y sus recursos.@apps.tool(resource_uri="ui://clock/app.html"): una herramienta normal, más la marca_meta.ui.resourceUri. Todo lo que acepta@mcp.tool()(name, title, description, ...) se pasa tal cual.apps.add_html_resource("ui://clock/app.html", CLOCK_HTML): el recurso correspondiente, servido comotext/html;profile=mcp-app. Ese tipo MIME exacto es lo que le dice a un host "esto es una app, muéstrala".MCPServer("clock", extensions=[apps]): la activación. El servidor ahora anunciaio.modelcontextprotocol/uibajocapabilities.extensions.
El HTML en sí escucha el postMessage del host y muestra el resultado. Para apps
reales, usa el SDK oficial de navegador @modelcontextprotocol/ext-apps
dentro de tu HTML. Te da ontoolresult, callServerTool,
getHostContext y onhostcontextchanged en lugar de eventos de mensaje sin procesar.
Degradación elegante
No todos los clientes muestran apps. La especificación es tajante sobre lo que eso significa para ti:
Tools MUST return a meaningful
contentarray even when UI is available.
El modelo lee content; el iframe es para humanos. Un host capaz de mostrar UI sigue entregando
el resultado en texto al modelo, y un cliente solo de texto recibe solo eso. Así que el
patrón canónico es una herramienta, dos respuestas. Mira get_time de nuevo:
from mcp import Client
from mcp.client import advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context
CLOCK_HTML = """\
<!doctype html>
<title>Clock</title>
<h1 id="now">...</h1>
<script>
window.addEventListener("message", (event) => {
const text = event.data?.result?.content?.[0]?.text;
if (text) document.getElementById("now").textContent = text;
});
</script>
"""
apps = Apps()
@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
def get_time(ctx: Context) -> str:
now = "2026-06-26T12:00:00Z"
if not client_supports_apps(ctx):
return f"The time is {now}."
return now
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
mcp = MCPServer("clock", extensions=[apps])
async def main() -> None:
async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client:
result = await client.call_tool("get_time", {})
print(result.content)
# [TextContent(text='2026-06-26T12:00:00Z')]
client_supports_apps(ctx) es True solo cuando el cliente declaró la
extensión io.modelcontextprotocol/ui y incluyó text/html;profile=mcp-app
en su configuración mimeTypes. El campo es obligatorio, así que un cliente que lo omite
no cuenta. Eso es exactamente lo que declara main() en el mismo archivo: la
mitad cliente de la negociación, y vuelve la respuesta enriquecida.
Warning
Nunca devuelvas un marcador de posición como "[Rendered UI]" como único contenido. Si el
texto alternativo es inútil, la herramienta es inútil para todos los clientes solo de texto y para
el propio modelo. Escribe la frase.
Blindar el iframe
El lado del recurso lleva los metadatos de seguridad: qué puede cargar el iframe, qué permisos del navegador quiere, cómo le gustaría que lo enmarcaran:
from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions
from mcp.server.mcpserver import MCPServer
DASHBOARD_HTML = "<!doctype html><title>Dashboard</title><canvas id='chart'></canvas>"
apps = Apps()
@apps.tool(resource_uri="ui://dashboard/app.html", visibility=["app"])
def refresh_dashboard() -> str:
"""Refresh the dashboard data."""
return "refreshed"
apps.add_html_resource(
"ui://dashboard/app.html",
DASHBOARD_HTML,
title="Dashboard",
csp=ResourceCsp(connect_domains=["https://api.example.com"]),
permissions=ResourcePermissions(clipboard_write={}),
domain="dashboard.example.com",
prefers_border=True,
)
mcp = MCPServer("dashboard", extensions=[apps])
csp y permissions son solicitudes al host, no comportamiento del servidor. El host
construye las políticas Content-Security-Policy y Permissions-Policy del iframe a partir de ellas, y
puede negarse. Detecta las funcionalidades en tu JS en lugar de suponer que se concedieron.
ResourceCsp, campo por campo (nombre en Python, clave en el canal, qué hace el host con ella):
| Python | Canal (_meta.ui.csp) |
Controla |
|---|---|---|
connect_domains |
connectDomains |
connect-src: adónde pueden ir fetch/XHR |
resource_domains |
resourceDomains |
img-src, style-src, ...: recursos estáticos |
frame_domains |
frameDomains |
frame-src: iframes anidados |
base_uri_domains |
baseUriDomains |
base-uri: a qué puede apuntar <base> |
ResourcePermissions: cada campo solicita un permiso del navegador para el iframe.
| Python | Canal (_meta.ui.permissions) |
|---|---|
camera |
camera |
microphone |
microphone |
geolocation |
geolocation |
clipboard_write |
clipboardWrite |
Note
La CSP y los permisos viven en el recurso, nunca en la herramienta. Los metadatos de herramienta
de la especificación no tienen hueco para ellos, y los hosts los ignoran ahí. El SDK hace que el
error sea imposible de representar: @apps.tool() simplemente no tiene parámetro csp.
Visibilidad
visibility=["app"] en una herramienta dice "esto existe para el iframe, no para el modelo":
"model": el modelo puede llamarla."app": el iframe puede llamarla (mediantecallServerTool).- Omitido: ambos, que es el valor por defecto.
Filtrar es tarea del host. El servidor lista las herramientas exclusivas de app en tools/list
como cualquier otra; el host las oculta al modelo. No filtres en el servidor.
Las reglas que el SDK hace cumplir
Todas estas fallan al arrancar, no en producción:
- Un
resource_urio una URI de recurso que no seaui://...es unValueErroren el momento de decorar o registrar. - Una herramienta vinculada a una URI sin un recurso registrado que corresponda es un
ValueErrorcuandoMCPServer(extensions=[apps])consume la extensión. Una herramienta que anuncia un HTML que responde 404 enresources/reades un error de configuración, así que se niega a construirse. meta={"ui": ...}en@apps.tool()es unValueError. El decorador es dueño de_meta["ui"]; exprésalo conresource_uri=yvisibility=. Otras claves demeta=se combinan sin problema al lado.
Ni el SDK ext-apps de TypeScript ni FastMCP detectan hoy ninguno de estos casos; preferimos que te enteres antes de que lo haga un host.
Más allá del HTML en línea
add_html_resource cubre el caso común: una cadena de HTML. Para cualquier otra cosa,
HTML en disco o contenido generado, construye el recurso tú mismo y entrégalo:
from pathlib import Path
from mcp.server.apps import Apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.resources import FileResource
REPORT_HTML = Path(__file__).parent / "report.html"
apps = Apps()
@apps.tool(resource_uri="ui://report/app.html")
def refresh_report() -> str:
"""Refresh the report data."""
return "report refreshed"
apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML))
mcp = MCPServer("report", extensions=[apps])
add_resource rellena el tipo MIME text/html;profile=mcp-app cuando el recurso
no fija uno explícitamente, y rechaza una discrepancia explícita: un recurso ui://
con cualquier otro tipo MIME es uno que ningún host va a mostrar.
Tip
¿Apuntas a un host previo a la disponibilidad general que todavía lee la clave plana
obsoleta _meta["ui/resourceUri"]? Combínala tú mismo:
@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"}).
El objeto ui anidado es la forma de la especificación; la clave plana está de salida.
Verlo en marcha
La historia apps en examples/stories/ es esta página en forma de pareja ejecutable: un servidor
con una herramienta de reloj vinculada a una UI y un cliente que negocia Apps, lee el
_meta.ui.resourceUri de la herramienta, obtiene el HTML y llama a la herramienta.
uv run python -m stories.apps.client