Aller au contenu

MCP Apps

Traduction automatique

Cette page a été traduite automatiquement à partir de la documentation en anglais, et la page en anglais fait foi. Si quelque chose vous semble incorrect, la page Traductions explique comment le signaler.

Une MCP App est un outil doté d’une interface : en plus de ses données, l’outil désigne un document HTML que l’hôte affiche comme surface interactive.

Deux parties, toujours deux parties :

  1. Un outil qui fait le travail et renvoie des données, comme n’importe quel autre outil.
  2. Une ressource ui:// contenant le HTML que l’hôte affiche pour lui.

L’outil porte une référence _meta.ui.resourceUri vers la ressource. L’hôte la récupère avec resources/read, l’affiche dans une iframe isolée (sandbox) et pousse le résultat de l’outil dans cette iframe via postMessage. Votre serveur n’envoie ni ne reçoit jamais de messages ui/* : ce trafic circule entre l’hôte et l’iframe. Vous servez un outil et un document HTML ; l’hôte se charge de la mise en scène.

Le SDK fournit cela sous la forme de l’extension intégrée Apps (io.modelcontextprotocol/ui). Si les extensions sont nouvelles pour vous, parcourez d’abord cette page. Une minute, puis revenez.

Une horloge avec un cadran

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')]

Quatre étapes :

  • Apps() : une seule instance contient vos outils liés à une interface et leurs ressources.
  • @apps.tool(resource_uri="ui://clock/app.html") : un outil ordinaire, plus le marquage _meta.ui.resourceUri. Tout ce que @mcp.tool() accepte (name, title, description, …) est transmis tel quel.
  • apps.add_html_resource("ui://clock/app.html", CLOCK_HTML) : la ressource correspondante, servie en text/html;profile=mcp-app. C’est ce type MIME exact qui indique à un hôte « ceci est une app, affichez-la ».
  • MCPServer("clock", extensions=[apps]) : vous activez l’extension. Le serveur annonce désormais io.modelcontextprotocol/ui sous capabilities.extensions.

Le HTML lui-même écoute le postMessage de l’hôte et affiche le résultat. Pour de vraies applications, utilisez dans votre HTML le SDK navigateur officiel @modelcontextprotocol/ext-apps. Il vous donne ontoolresult, callServerTool, getHostContext et onhostcontextchanged au lieu d’événements de message bruts.

Dégradation gracieuse

Tous les clients n’affichent pas les apps. La spécification dit sans détour ce que cela implique pour vous :

Les outils DOIVENT renvoyer un tableau content significatif même lorsqu’une interface est disponible.

Le modèle lit content ; l’iframe est pour les humains. Un hôte capable d’afficher une interface transmet quand même le résultat textuel au modèle, et un client purement textuel ne reçoit que cela. Le schéma canonique est donc : un outil, deux réponses. Regardez à nouveau get_time :

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) ne vaut True que lorsque le client a déclaré l’extension io.modelcontextprotocol/ui et listé text/html;profile=mcp-app dans ses paramètres mimeTypes. Le champ est obligatoire, donc un client qui l’omet ne compte pas. C’est exactement ce que déclare main() dans le même fichier : la moitié client de la négociation, et la réponse riche revient.

Warning

Ne renvoyez jamais un texte de substitution comme "[Rendered UI]" pour seul contenu. Si le texte de repli est inutile, l’outil est inutile pour tout client purement textuel et pour le modèle lui-même. Écrivez la phrase.

Verrouiller l’iframe

C’est le côté ressource qui porte les métadonnées de sécurité : ce que l’iframe peut charger, les permissions du navigateur qu’elle souhaite, la façon dont elle aimerait être encadrée :

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 et permissions sont des demandes adressées à l’hôte, pas un comportement du serveur. L’hôte construit à partir d’elles la Content-Security-Policy et la Permissions-Policy de l’iframe, et il peut refuser. Faites de la détection de fonctionnalités dans votre JS plutôt que de supposer l’accord acquis.

ResourceCsp, champ par champ (nom Python, clé sur la liaison, ce que l’hôte en fait) :

Python Liaison (_meta.ui.csp) Contrôle
connect_domains connectDomains connect-src : où fetch/XHR peuvent aller
resource_domains resourceDomains img-src, style-src, … : fichiers statiques
frame_domains frameDomains frame-src : iframes imbriquées
base_uri_domains baseUriDomains base-uri : ce vers quoi <base> peut pointer

ResourcePermissions : chaque champ demande une permission du navigateur pour l’iframe.

Python Liaison (_meta.ui.permissions)
camera camera
microphone microphone
geolocation geolocation
clipboard_write clipboardWrite

Note

La CSP et les permissions vivent sur la ressource, jamais sur l’outil. Les métadonnées d’outil de la spécification n’ont pas d’emplacement pour elles, et les hôtes les ignorent à cet endroit. Le SDK rend l’erreur impossible à exprimer : @apps.tool() n’a tout simplement pas de paramètre csp.

Visibilité

visibility=["app"] sur un outil dit « ceci existe pour l’iframe, pas pour le modèle » :

  • "model" : le modèle peut l’appeler.
  • "app" : l’iframe peut l’appeler (via callServerTool).
  • Omis : les deux, ce qui est la valeur par défaut.

Le filtrage est le travail de l’hôte. Votre serveur liste les outils réservés à l’app dans tools/list comme les autres ; l’hôte les cache au modèle. Ne filtrez pas côté serveur.

Les règles que le SDK fait respecter

Toutes échouent au démarrage, pas en production :

  • Un resource_uri ou un URI de ressource qui n’est pas ui://... lève une ValueError au moment de la décoration ou de l’enregistrement.
  • Un outil lié à un URI sans ressource enregistrée correspondante lève une ValueError lorsque MCPServer(extensions=[apps]) consomme l’extension. Un outil qui annonce du HTML répondant 404 sur resources/read est une erreur de configuration, donc le serveur refuse de se construire.
  • meta={"ui": ...} sur @apps.tool() lève une ValueError. Le décorateur est propriétaire de _meta["ui"] ; exprimez-le avec resource_uri= et visibility=. Les autres clés meta= se fusionnent sans problème à côté.

Ni le SDK TypeScript ext-apps ni FastMCP ne détectent ces cas aujourd’hui ; nous préférons que vous le découvriez avant qu’un hôte ne le fasse.

Au-delà du HTML inline

add_html_resource couvre le cas courant : une chaîne de HTML. Pour tout le reste, HTML sur disque ou contenu généré, construisez la ressource vous-même et transmettez-la :

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 renseigne le type MIME text/html;profile=mcp-app quand la ressource n’en définit pas explicitement, et rejette une incohérence explicite : une ressource ui:// sous tout autre type MIME est une ressource qu’aucun hôte n’affichera.

Tip

Vous ciblez un hôte d’avant la disponibilité générale qui lit encore la clé plate obsolète _meta["ui/resourceUri"] ? Fusionnez-la vous-même : @apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"}). L’objet ui imbriqué est la forme prévue par la spécification ; la clé plate est en voie de disparition.

Le voir en action

Le scénario apps dans examples/stories/, c’est cette page sous forme de paire exécutable : un serveur avec un outil horloge lié à une interface et un client qui négocie Apps, lit le _meta.ui.resourceUri de l’outil, récupère le HTML et appelle l’outil.

uv run python -m stories.apps.client