Ajouter une interface avec MCP Apps
Sur cette page
Leçon réécrite le 3 novembre 2026 d’après la page Add UI to your MCP server de la documentation Plugins d’OpenAI et le code de
@modelcontextprotocol/ext-apps1.7.5. L’ancienne leçon décrivait des « widgets » (defineWidget) d’un SDK inexistant ; une interface est une ressource MCP Apps rendue par ChatGPT dans une iframe. Sources : Add UI to your MCP server · Spécification MCP Apps.
Quand ajouter une interface
L’interface reste facultative. Ajoutez-la quand l’utilisateur doit inspecter, comparer, modifier, confirmer ou parcourir des informations structurées — et gardez vos outils utiles sans elle, pour que ChatGPT et Codex puissent mener le workflow dans les clients qui n’affichent pas de composant.
Le principe de MCP Apps
ChatGPT implémente le standard ouvert MCP Apps. Le serveur déclare une ressource d’interface, identifiée par une URI ui://… et servie avec le type MIME text/html;profile=mcp-app ; un outil s’y rattache par _meta.ui.resourceUri. Quand cet outil est appelé, ChatGPT affiche la ressource dans une iframe, à côté de la conversation. Le composant dialogue avec l’hôte en JSON-RPC par postMessage :
| Message | Rôle |
|---|---|
ui/initialize puis ui/notifications/initialized | Ouvrir le dialogue avec l’hôte |
ui/notifications/tool-input | Recevoir les arguments de l’appel d’outil |
ui/notifications/tool-result | Recevoir le résultat de l’outil |
tools/call | Appeler un outil du serveur depuis l’interface |
ui/message | Envoyer un message de suivi dans la conversation |
ui/update-model-context | Informer le modèle d’une sélection ou d’une modification en cours |
Côté serveur : déclarer la ressource et l’outil
Reprenons le serveur de notes de la leçon 4. Le paquet @modelcontextprotocol/ext-apps (en 1.x avec le SDK v1) fournit deux aides : registerAppResource, qui applique par défaut le bon type MIME exposé par la constante RESOURCE_MIME_TYPE, et registerAppTool, qui exige la métadonnée d’interface.
import { readFileSync } from "node:fs";
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
const URI_CARTE = "ui://widget/notes-v1.html"; // versionnée : c'est une clé de cache
const carteHtml = readFileSync("public/notes-widget.html", "utf8");
// À ajouter dans creerServeur(), à côté de list_notes et add_note
registerAppResource(server, "notes-widget", URI_CARTE, {}, async () => ({
contents: [{ uri: URI_CARTE, mimeType: RESOURCE_MIME_TYPE, text: carteHtml }],
}));
registerAppTool(
server,
"show_notes",
{
title: "Afficher les notes",
description:
"Affiche les notes dans une carte. Pour n'en montrer qu'une partie, appelle d'abord list_notes.",
inputSchema: {},
outputSchema: schemaNotes,
annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: URI_CARTE } },
},
async () => ({
structuredContent: { notes },
content: [{ type: "text", text: `${notes.length} note(s) affichée(s).` }],
})
);
Seul show_notes porte l’interface. C’est le découplage que recommande OpenAI : si chaque outil renvoyait un gabarit, ChatGPT redessinerait l’iframe trop souvent. Les outils de données (list_notes, add_note) cherchent, calculent ou modifient et renvoient seulement leur résultat ; les outils de rendu reçoivent des données finales et renvoient le gabarit. Le modèle peut ainsi travailler sur les données — filtrer, recouper — avant de décider d’afficher, et la carte ne s’affiche qu’une fois, avec un contexte vérifié. Indiquez la dépendance dans la description de l’outil de rendu, comme ci-dessus.
Côté iframe : le pont JSON-RPC
Le composant tient ici dans un seul fichier HTML, sans étape de construction. Il ouvre le dialogue avec l’hôte, puis affiche chaque résultat d’outil qu’il reçoit.
<!doctype html>
<html lang="fr">
<head><meta charset="utf-8" /><title>Notes</title></head>
<body>
<ul id="liste"></ul>
<script type="module">
const liste = document.querySelector("#liste");
const enAttente = new Map();
let dernierId = 0;
const envoyer = (message) =>
window.parent.postMessage({ jsonrpc: "2.0", ...message }, "*");
const requete = (method, params) =>
new Promise((resolve, reject) => {
const id = ++dernierId;
enAttente.set(id, { resolve, reject });
envoyer({ id, method, params });
});
const afficher = (resultat) => {
const notes = resultat?.structuredContent?.notes ?? [];
liste.replaceChildren(
...notes.map((note) => {
const li = document.createElement("li");
li.textContent = note.text; // jamais innerHTML sur des données reçues
return li;
})
);
};
window.addEventListener("message", (event) => {
if (event.source !== window.parent) return;
const msg = event.data;
if (msg?.jsonrpc !== "2.0") return;
if (typeof msg.id === "number" && enAttente.has(msg.id)) {
const { resolve, reject } = enAttente.get(msg.id);
enAttente.delete(msg.id);
msg.error ? reject(msg.error) : resolve(msg.result);
} else if (msg.method === "ui/notifications/tool-result") {
afficher(msg.params);
}
});
await requete("ui/initialize", {
appInfo: { name: "notes-widget", version: "0.1.0" },
appCapabilities: {},
protocolVersion: "2026-01-26",
});
envoyer({ method: "ui/notifications/initialized", params: {} });
</script>
</body>
</html>
Le résultat reçu est une donnée non fiable : affichez-le avec textContent, jamais en l’injectant comme HTML. Pour une interface plus riche, le même paquet fournit côté navigateur une classe App qui encapsule ce pont (réception des résultats, appel d’outils), à intégrer dans un bundle construit avec esbuild ; le dépôt d’exemples d’OpenAI montre des composants React complets.
Choisir une présentation
Commencez par l’affichage en ligne et ne demandez plus d’espace que si le workflow l’exige :
| Présentation | Usage |
|---|---|
| Carte en ligne | Un résultat ciblé, une confirmation, quelques actions, sans navigation profonde |
| Carrousel en ligne | Parcourir et choisir parmi quelques options semblables, visuelles |
| Plein écran | Une tâche riche : carte, canevas d’édition, exploration détaillée ; la zone de saisie de ChatGPT reste disponible |
| Image dans l’image | Une activité en cours qui doit rester visible pendant la conversation : session en direct, jeu, vidéo |
Les extensions propres à ChatGPT
Une fois le flux MCP Apps en place, ChatGPT propose des extensions par window.openai, à n’utiliser que pour ce que le standard ne couvre pas. Pour les besoins communs, préférez le standard ; les alias de ChatGPT restent disponibles pour les intégrations existantes :
| Besoin | Standard MCP Apps | Alias ChatGPT |
|---|---|---|
| Rattacher un outil à une interface | _meta.ui.resourceUri | _meta["openai/outputTemplate"] |
| Recevoir les arguments | ui/initialize + ui/notifications/tool-input | window.openai.toolInput |
| Recevoir le résultat | ui/notifications/tool-result | window.openai.toolOutput |
| Appeler un outil | tools/call | window.openai.callTool |
| Envoyer un message de suivi | ui/message | window.openai.sendFollowUpMessage |
Les extensions sans équivalent standard couvrent le paiement intégré (requestCheckout), les fichiers (uploadFile, selectFiles, getFileDownloadUrl), les fenêtres modales gérées par l’hôte (requestModal) et la persistance de l’état du composant (widgetState, setWidgetState). Détectez chaque capacité avant de vous en servir, avec une solution de repli quand c’est possible — et ne testez jamais le nom de l’hôte, testez la fonction :
const openai = typeof window !== "undefined" ? window.openai : undefined;
if (openai?.requestModal) {
await openai.requestModal({ template: "ui://widget/details-v1.html" });
} else {
document.body.textContent = "Cet hôte ne prend pas en charge les fenêtres modales.";
}
Trois sortes d’état
| État | Propriétaire | Durée de vie | Exemples |
|---|---|---|---|
| Données métier, qui font foi | Serveur MCP ou service externe | Longue | Tâches, tickets, documents |
| État d’interface, éphémère | L’instance du composant | Le temps de son affichage | Ligne sélectionnée, panneau ouvert, tri |
| État durable entre sessions | Un stockage que vous contrôlez | Entre conversations et appareils | Filtres enregistrés, mode d’affichage |
Les données métier restent sur le serveur : l’interface appelle un outil, le serveur valide et met à jour, puis renvoie l’état de référence, que l’interface affiche en conservant ce qui relève de la présentation. Quand le modèle doit connaître une sélection, envoyez-la par ui/update-model-context. window.openai.setWidgetState (synchrone, sans await) conserve l’état d’une instance, pas des données métier. L’état durable va dans votre stockage, derrière une authentification ; évitez localStorage, qui n’offre ni continuité entre appareils ni fiabilité dans une iframe isolée.
Politique de sécurité du contenu
Le composant déclare, dans les métadonnées de sa ressource (_meta.ui.csp), les domaines exacts qu’il utilise : connectDomains pour les appels d’API, resourceDomains pour les scripts, styles et images, frameDomains seulement s’il doit intégrer des iframes précises — les cadres imbriqués sont bloqués par défaut. Gardez ces listes aussi étroites que possible : la revue du plugin compare la politique déclarée au comportement réel de l’interface. Et traitez l’URI de la ressource comme une clé de cache : à chaque changement incompatible du HTML, du JavaScript ou du CSS, publiez une nouvelle URI et mettez à jour les outils qui la référencent.
Deux détails pour finir : l’hôte reporte la langue de l’utilisateur dans document.documentElement.lang, à utiliser pour traduire et formater dates et nombres ; et la bibliothèque @openai/apps-sdk-ui fournit des boutons, cartes et champs assortis à ChatGPT si vous ne voulez pas tout redessiner.
Points clés à retenir
- Une interface est une ressource
ui://de typetext/html;profile=mcp-app, rattachée à un outil par_meta.ui.resourceUri - Le composant dialogue avec l’hôte en JSON-RPC (
ui/initialize,tools/call,ui/notifications/tool-result…) - Séparez outils de données et outils de rendu : seul l’outil de rendu porte le gabarit
window.openain’apporte que des extensions, à détecter avant usage- Données métier sur le serveur, CSP étroite, URI de ressource versionnée