Saltar a contenido

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:

  1. Una herramienta que hace el trabajo y devuelve datos, como cualquier otra herramienta.
  2. 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

server.py
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 como text/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 anuncia io.modelcontextprotocol/ui bajo capabilities.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 content array 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:

server.py
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:

server.py
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 (mediante callServerTool).
  • 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_uri o una URI de recurso que no sea ui://... es un ValueError en el momento de decorar o registrar.
  • Una herramienta vinculada a una URI sin un recurso registrado que corresponda es un ValueError cuando MCPServer(extensions=[apps]) consume la extensión. Una herramienta que anuncia un HTML que responde 404 en resources/read es un error de configuración, así que se niega a construirse.
  • meta={"ui": ...} en @apps.tool() es un ValueError. El decorador es dueño de _meta["ui"]; exprésalo con resource_uri= y visibility=. Otras claves de meta= 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:

server.py
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