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 :
- Un outil qui fait le travail et renvoie des données, comme n’importe quel autre outil.
- 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
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 entext/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ésormaisio.modelcontextprotocol/uisouscapabilities.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
contentsignificatif 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 :
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 :
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 (viacallServerTool).- 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_uriou un URI de ressource qui n’est pasui://...lève uneValueErrorau moment de la décoration ou de l’enregistrement. - Un outil lié à un URI sans ressource enregistrée correspondante lève une
ValueErrorlorsqueMCPServer(extensions=[apps])consomme l’extension. Un outil qui annonce du HTML répondant 404 surresources/readest une erreur de configuration, donc le serveur refuse de se construire. meta={"ui": ...}sur@apps.tool()lève uneValueError. Le décorateur est propriétaire de_meta["ui"]; exprimez-le avecresource_uri=etvisibility=. Les autres clésmeta=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 :
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