Votre serveur MCP ne sait parler qu'en texte
Vous avez construit un serveur MCP. Il interroge votre base, appelle votre API de facturation, extrait les chiffres de votre entrepôt de données. Il fonctionne. Puis un utilisateur demande à l'assistant le chiffre d'affaires du dernier trimestre par région, et votre outil soigneusement conçu renvoie un mur de cellules de tableau markdown que le modèle re-résume ensuite, mal, en un paragraphe.
Certaines données ne veulent pas être un paragraphe. Une carte thermique de rétention ne veut pas être un paragraphe. Une carte, un curseur de budget, une page PDF, un plan de salle, une scène 3D — tout cela veut des pixels et une souris, pas des tokens.
C'est précisément l'écart que comble MCP Apps. Ratifiée sous le nom SEP-1865 et stable depuis la révision 2026-01-26 de la spécification, c'est la première extension officielle du Model Context Protocol, co-conçue par la communauté MCP-UI, Anthropic et OpenAI. Elle permet à votre serveur de livrer une vue HTML aux côtés d'un outil, et à cette vue de dialoguer avec votre serveur via le même protocole JSON-RPC que MCP utilise déjà. Claude, ChatGPT, VS Code, Goose et Postman l'affichent tous.
Ce tutoriel en construit une de bout en bout.
Ce que vous allez construire
Une application MCP de tableau de bord de revenus : un outil nommé get-revenue qui renvoie les chiffres trimestriels et les affiche sous forme de vue React interactive dans l'hôte, avec :
- Un graphique et un filtre par région cliquable, sans aucun aller-retour vers le modèle
- Un bouton d'actualisation qui rappelle votre serveur MCP directement depuis l'interface
- Un outil réservé à l'app, que le modèle ne voit ni ne peut appeler, dédié aux actions déclenchées par l'interface
- Une thématisation automatique pour que la vue épouse le mode clair ou sombre et les polices de l'hôte
- Une politique de sécurité de contenu déclarée, car l'hôte vous place en bac à sable par défaut
- Un appel à
ui/update-model-contextpour que l'assistant sache ce que regarde l'utilisateur - Un état de vue qui survit à un défilement puis un retour
À la fin, vous aurez environ 250 lignes de code applicatif et un modèle mental juste de la frontière de confiance entre un hôte, une vue et un serveur.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ et un gestionnaire de paquets (ce guide utilise
pnpm, npm fonctionne à l'identique) - De l'expérience dans la construction d'un serveur MCP — outils et ressources doivent vous être familiers. Sinon, commencez par notre guide Créer un serveur MCP en TypeScript
- Une pratique de React et TypeScript — la vue est une application React ordinaire, simplement empaquetée d'une manière inhabituelle
- Un hôte MCP compatible avec l'extension Apps pour le test final : Claude Desktop, VS Code, ou l'hôte de référence livré dans le dépôt ext-apps
Vous n'avez pas besoin de connaître les iframes en profondeur. Le SDK gère le transport ; ce qu'il faut comprendre, ce sont les conséquences de sécurité, et ce tutoriel les traite explicitement.
Étape 1 : le modèle mental
Une application MCP, ce sont deux primitives MCP que vous connaissez déjà, reliées par un unique champ de métadonnées.
- Une ressource dont l'URI commence par
ui://et dont le type MIME esttext/html;profile=mcp-app. Son contenu est un document HTML5 complet et autonome — votre vue empaquetée. - Un outil dont
_meta.ui.resourceUripointe vers cet URI de ressource.
Quand le modèle appelle l'outil sur un hôte compatible, l'hôte fait trois choses : il lit la ressource référencée, affiche le HTML dans une iframe en bac à sable, puis transmet le résultat de l'outil à la vue en cours d'exécution. La vue se comporte alors comme un client MCP : elle peut appeler tools/call et resources/read, et l'hôte relaie ces appels vers votre serveur.
Le cycle de vie, dans l'ordre :
| Étape | Acteur | Message |
|---|---|---|
| 1 | Modèle | tools/call pour get-revenue |
| 2 | Hôte | resources/read sur ui://revenue/dashboard.html |
| 3 | Hôte | Affiche le HTML dans le bac à sable |
| 4 | Vue | Requête ui/initialize vers l'hôte |
| 5 | Hôte | Renvoie hostContext — thème, locale, taille du conteneur |
| 6 | Vue | ui/notifications/initialized |
| 7 | Hôte | Livre le résultat de l'outil à la vue |
| 8 | Vue | Appelle tools/call / ui/update-model-context au fil des interactions |
Deux décisions de conception méritent qu'on s'y arrête. Les gabarits sont prédéclarés, si bien qu'un hôte peut inspecter et précharger chaque interface qu'un serveur pourrait afficher avant même qu'un outil ne s'exécute. Et chaque message entre la vue et l'hôte est du JSON-RPC, donc journalisable et auditable — rien ne passe par un canal parallèle opaque.
L'extension est identifiée par
io.modelcontextprotocol/uiet fait l'objet d'une négociation à la connexion. Un hôte qui ne la prend pas en charge se rabat simplement sur le contenu textuel de votre outil : un serveur compatible Apps se dégrade donc proprement sur les clients plus anciens.
Étape 2 : mise en place du projet
Créez le projet et installez les dépendances :
mkdir revenue-app && cd revenue-app
pnpm init
pnpm pkg set type=module
pnpm add @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors react react-dom
pnpm add -D typescript vite vite-plugin-singlefile @vitejs/plugin-react \
@types/express @types/cors @types/node @types/react @types/react-dom \
tsx concurrently cross-envLa dépendance inhabituelle est vite-plugin-singlefile. Votre vue doit être livrée comme un seul document HTML — l'hôte lit une ressource unique, pas un répertoire d'actifs. Ce plugin intègre chaque script et chaque feuille de style directement dans le HTML produit.
Ajoutez les scripts de build :
pnpm pkg set scripts.build="tsc --noEmit && cross-env INPUT=mcp-app.html vite build"
pnpm pkg set scripts.dev='concurrently --raw "cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch" "tsx watch main.ts"'Puis vite.config.ts :
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
const INPUT = process.env.INPUT;
if (!INPUT) throw new Error("INPUT environment variable is not set");
const isDevelopment = process.env.NODE_ENV === "development";
export default defineConfig({
plugins: [react(), viteSingleFile()],
build: {
// Sourcemaps intégrées seulement en dev — le bundle voyage dans une charge JSON-RPC
sourcemap: isDevelopment ? "inline" : undefined,
cssMinify: !isDevelopment,
minify: !isDevelopment,
rollupOptions: { input: INPUT },
outDir: "dist",
emptyOutDir: false,
},
});Gardez emptyOutDir: false. La sortie compilée de votre serveur atterrit dans le même dossier dist, et vous ne voulez pas que le build de la vue l'efface à chaque reconstruction.
Étape 3 : enregistrer l'outil et la ressource UI
C'est le cœur du sujet. Créez server.ts :
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { fetchRevenue } from "./data.js";
const DIST_DIR = path.join(import.meta.dirname, "dist");
// Un seul URI, référencé par les deux enregistrements. Gardez-le dans une constante —
// une faute de frappe ici échoue en silence : « l'outil marche, aucune UI n'apparaît ».
const RESOURCE_URI = "ui://revenue/dashboard.html";
export function createServer(): McpServer {
const server = new McpServer({
name: "Revenue Dashboard",
version: "1.0.0",
});
registerAppTool(
server,
"get-revenue",
{
title: "Get Revenue",
description: "Show quarterly revenue broken down by region.",
inputSchema: { quarter: z.string().describe("e.g. 2026-Q2") },
_meta: { ui: { resourceUri: RESOURCE_URI } }, // [!code highlight]
},
async ({ quarter }) => {
const rows = await fetchRevenue(quarter);
const total = rows.reduce((sum, r) => sum + r.revenue, 0);
return {
// `content` est ce que lit le modèle — restez bref
content: [
{ type: "text", text: `Revenue for ${quarter}: ${total} TND across ${rows.length} regions.` },
],
// `structuredContent` est ce que lit la vue — mettez-y la charge complète
structuredContent: { quarter, rows, total },
};
},
);
registerAppResource(
server,
"revenue-dashboard",
RESOURCE_URI,
{ mimeType: RESOURCE_MIME_TYPE },
async () => {
const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
return {
contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
};
},
);
return server;
}RESOURCE_MIME_TYPE est la constante correspondant à text/html;profile=mcp-app. Utilisez-la plutôt que de saisir la chaîne : le paramètre de profil est facile à écorcher subtilement.
La séparation entre content et structuredContent est l'habitude la plus rentable de tout ce tutoriel. content entre dans la fenêtre de contexte du modèle et coûte des tokens à chaque tour. structuredContent est transmis à votre vue comme une prop React et, dans les hôtes qui le gèrent, reste hors du contexte du modèle. Un outil qui renvoie 400 lignes de données doit renvoyer une phrase dans content et le tableau entier dans structuredContent.
Si vous avez croisé l'ancienne clé plate
_meta["ui/resourceUri"]dans des billets de fin 2025, elle fonctionne encore mais elle est dépréciée et sera retirée avant la GA. Utilisez la forme imbriquée_meta.ui.resourceUri.
Étape 4 : construire la vue
La coquille HTML, mcp-app.html :
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Revenue Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/mcp-app.tsx"></script>
</body>
</html>Maintenant src/mcp-app.tsx. Le hook useApp crée une instance App, vous laisse enregistrer vos gestionnaires avant la connexion, puis se connecte :
import type { App } from "@modelcontextprotocol/ext-apps";
import { useApp, useHostStyleVariables } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
interface RevenueRow {
region: string;
revenue: number;
}
interface RevenuePayload {
quarter: string;
rows: RevenueRow[];
total: number;
}
function RevenueApp() {
const [data, setData] = useState<RevenuePayload | null>(null);
const { app, error } = useApp({
appInfo: { name: "Revenue Dashboard", version: "1.0.0" },
capabilities: {},
onAppCreated: (app) => {
// À enregistrer AVANT connect(), sinon le premier résultat d'outil est perdu
app.ontoolresult = (result: CallToolResult) => { // [!code highlight]
setData(result.structuredContent as unknown as RevenuePayload);
};
app.onerror = console.error;
},
});
// Adopter les variables CSS et le jeu de couleurs de l'hôte
useHostStyleVariables(app, app?.getHostContext());
if (error) return <p>Failed to connect: {error.message}</p>;
if (!app || !data) return <p>Loading…</p>;
return <Dashboard app={app} data={data} onData={setData} />;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RevenueApp />
</StrictMode>,
);Le commentaire sur l'ordre compte plus qu'il n'y paraît. L'hôte livre le résultat de l'outil dès que la vue envoie ui/notifications/initialized. Si vous affectez ontoolresult après la résolution de connect(), vous avez une condition de course : elle se déclenchera sur une machine lente et passera inaperçue sur la vôtre. onAppCreated existe précisément pour supprimer cette fenêtre.
Vous préférez du JavaScript nu ou un autre framework ? Le même cycle de vie fonctionne sans React :
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "Revenue Dashboard", version: "1.0.0" });
app.ontoolresult = (result) => render(result.structuredContent);
app.connect();Le dépôt ext-apps propose des exemples de démarrage officiels pour Vue, Svelte, Preact, Solid et JavaScript nu — la moitié serveur y est rigoureusement identique.
Étape 5 : laisser la vue répondre
Une vue qui se contente d'afficher n'est qu'une image. L'intérêt est dans le chemin de retour. app.callServerTool() envoie un tools/call à travers l'hôte jusqu'à votre serveur et résout avec le résultat :
function Dashboard({ app, data, onData }: {
app: App;
data: RevenuePayload;
onData: (d: RevenuePayload) => void;
}) {
const [busy, setBusy] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
async function refresh() {
setBusy(true);
try {
const result = await app.callServerTool({
name: "get-revenue",
arguments: { quarter: data.quarter },
});
onData(result.structuredContent as unknown as RevenuePayload);
} catch (e) {
console.error("Refresh failed", e);
} finally {
setBusy(false);
}
}
const rows = selected ? data.rows.filter((r) => r.region === selected) : data.rows;
const max = Math.max(...data.rows.map((r) => r.revenue));
return (
<main style={{ fontFamily: "var(--font-sans)", color: "var(--color-text-primary)" }}>
<header>
<h2>{data.quarter}</h2>
<button onClick={refresh} disabled={busy}>
{busy ? "Refreshing…" : "Refresh"}
</button>
</header>
{rows.map((row) => (
<div key={row.region} onClick={() => setSelected(row.region)}>
<span>{row.region}</span>
<div style={{ width: `${(row.revenue / max) * 100}%` }} role="presentation" />
<span>{row.revenue.toLocaleString()} TND</span>
</div>
))}
{selected && <button onClick={() => setSelected(null)}>Clear filter</button>}
</main>
);
}Filtrer par région ne coûte ni tokens ni latence, puisque cela ne quitte jamais l'iframe. C'est tout l'argument économique de MCP Apps : les interactions de présentation ne devraient pas être payées en inférence.
La classe App expose bien plus que des appels d'outils. Celles que vous utiliserez le plus :
| Méthode | Rôle |
|---|---|
callServerTool() | Invoquer un outil de votre serveur MCP via l'hôte |
readServerResource() | Lire n'importe quelle ressource du serveur — utile pour les blobs binaires |
sendMessage() | Insérer un message dans la conversation comme si l'utilisateur l'avait saisi |
updateModelContext() | Informer le modèle de ce qu'affiche la vue, sans tour de conversation |
openLink() | Demander à l'hôte d'ouvrir une URL externe |
sendLog() | Émettre une ligne de journal que l'hôte peut exposer au développeur |
requestDisplayMode() | Demander inline, fullscreen ou pip |
Chacune peut être refusée. L'hôte est la frontière de confiance, et openLink comme sendMessage renvoient un résultat portant isError en cas de refus plutôt que de lever une exception. Vérifiez-le et dégradez proprement — afficher l'URL brute pour une copie manuelle vaut mieux qu'un bouton mort.
Étape 6 : les outils réservés à l'app
Voici le motif que la plupart des lecteurs manquent à la première lecture. Votre bouton d'actualisation appelle get-revenue, un outil que le modèle peut appeler lui aussi. Souvent, vous voulez l'inverse : une action que seule l'interface devrait déclencher — persister un filtre, acquitter une alerte, paginer un gros résultat — sans aucune raison de figurer dans la liste d'outils du modèle, où elle consomme des tokens de schéma et invite aux erreurs.
Positionnez visibility: ["app"] :
registerAppTool(
server,
"save-dashboard-filter",
{
description: "Persist the user's selected region filter.",
inputSchema: { viewId: z.string(), region: z.string().nullable() },
_meta: {
ui: {
resourceUri: RESOURCE_URI,
visibility: ["app"], // [!code highlight]
},
},
},
async ({ viewId, region }) => {
await saveFilter(viewId, region);
return { content: [{ type: "text", text: "ok" }] };
},
);L'hôte doit désormais exclure cet outil du tools/list tel que l'agent le voit, et rejeter tout tools/call le concernant qui ne provient pas d'une app sur la même connexion serveur. Les appels inter-serveurs vers des outils réservés à l'app sont toujours bloqués. En l'absence de visibility, la valeur par défaut est ["model", "app"] : un outil ordinaire reste donc appelable des deux côtés.
Étape 7 : adopter le thème de l'hôte
Une vue qui s'affiche en rectangle blanc au milieu d'une conversation Claude en mode sombre a l'air cassée, quelle que soit la qualité du graphique. Pendant ui/initialize, l'hôte renvoie un hostContext contenant une table styles.variables de propriétés CSS personnalisées, un theme valant light ou dark, du CSS @font-face optionnel, la locale et le timeZone de l'utilisateur, la plateforme, et les safeAreaInsets pour le mobile.
useHostStyleVariables — branché à l'étape 4 — écrit ces variables sur document.documentElement et fixe color-scheme pour que la fonction CSS light-dark() se résolve correctement. Ensuite, stylez à partir des variables :
:root {
color-scheme: light dark;
}
main {
background: var(--color-background-primary, #fff);
color: var(--color-text-primary, #171717);
font-family: var(--font-sans, system-ui, sans-serif);
}Fournissez toujours une valeur de repli dans var(). Les hôtes ne sont pas tenus d'envoyer toutes les variables, et une variable absente sans repli s'affiche en texte transparent sur fond transparent.
Pour les hôtes en langue arabe, lisez hostContext.locale et fixez la direction explicitement — l'hôte ne le fera pas pour vous :
const locale = app.getHostContext()?.locale ?? "en";
const dir = locale.startsWith("ar") ? "rtl" : "ltr";
// puis : <main dir={dir}>Le contexte de l'hôte n'est pas figé. Enregistrez app.onhostcontextchanged pour réagir quand l'utilisateur bascule en mode sombre ou redimensionne sa fenêtre en cours de session.
Étape 8 : déclarer votre CSP
C'est ici qu'un build local qui marche rencontre un panneau vide en production. L'hôte place votre vue en bac à sable et, si vous ne déclarez rien, applique une politique délibérément hostile :
default-src 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
media-src 'self' data:;
connect-src 'none';
Lisez connect-src 'none' attentivement : votre vue ne peut effectuer aucune requête réseau par défaut. Pas de fetch vers votre API, pas de bibliothèque de graphiques depuis un CDN, pas d'image distante. C'est intentionnel — un serveur hostile ne doit pas pouvoir exfiltrer une conversation via une URL d'image.
Tout élément externe doit être déclaré dans le _meta.ui de la ressource :
registerAppResource(
server,
"revenue-dashboard",
RESOURCE_URI,
{ mimeType: RESOURCE_MIME_TYPE },
async () => ({
contents: [{
uri: RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: {
ui: {
csp: {
connectDomains: ["https://api.example.com"],
resourceDomains: ["https://cdn.jsdelivr.net", "https://*.example-cdn.com"],
},
permissions: { clipboardWrite: {} },
prefersBorder: true,
},
},
}],
}),
);Trois règles à intégrer. Les hôtes peuvent restreindre davantage mais ne doivent jamais assouplir ce que vous déclarez : un domaine oublié est un domaine perdu. Les permissions — caméra, micro, géolocalisation, écriture presse-papiers — sont des demandes, pas des octrois ; détectez la capacité à l'exécution et dégradez. Et prefersBorder doit être renseigné explicitement, car les valeurs par défaut varient d'un hôte à l'autre : sans valeur, votre carte peut recevoir un cadre ou non.
La voie de moindre résistance consiste à n'avoir aucune dépendance externe : empaquetez tout via viteSingleFile, faites transiter toutes les données par structuredContent, et ne déclarez aucun domaine. Votre vue tourne alors sous la politique la plus stricte possible et fonctionne dans tous les hôtes sans négociation.
Étape 9 : garder le modèle dans la boucle
L'utilisateur filtre sur Sfax, puis tape « pourquoi celle-là baisse ? » — et le modèle n'a aucune idée de ce qu'est « celle-là », puisque le filtrage s'est produit dans une iframe qu'il ne voit pas.
updateModelContext résout exactement cela. Elle pousse une mise à jour de contexte sans créer de tour de conversation visible :
async function selectRegion(region: string) {
setSelected(region);
const row = data.rows.find((r) => r.region === region);
const markdown = `---
selected-region: ${region}
revenue: ${row?.revenue ?? 0}
quarter: ${data.quarter}
---
The user is now viewing the ${region} region in the revenue dashboard.`;
await app.updateModelContext({ content: [{ type: "text", text: markdown }] });
}Le frontmatter YAML est la convention qu'utilisent les exemples de la spécification eux-mêmes — il s'analyse de façon fiable et se lit bien dans une fenêtre de contexte. La même méthode est la bonne façon de signaler une vue dégradée : si getUserMedia est refusé ou qu'une bibliothèque de graphiques ne se charge pas, dites au modèle que cette capacité est indisponible, pour qu'il cesse de suggérer à l'utilisateur un bouton qui ne fonctionnera pas.
Utilisez-la avec discernement, cependant. Chaque mise à jour consomme du contexte. Déclenchez-la sur des changements d'état significatifs, pas à chaque mouvement de souris.
Étape 10 : dimensions, persistance et modes d'affichage
Dimensions. hostContext.containerDimensions vous indique quels axes vous contrôlez. Un champ height signifie que l'hôte l'a fixé et que votre vue doit le remplir avec 100vh. Un champ maxHeight signifie que vous contrôlez la hauteur jusqu'à cette borne. Ni l'un ni l'autre signifie sans limite. Avec des dimensions flexibles, le SDK envoie automatiquement ui/notifications/size-changed via un ResizeObserver, avec anti-rebond — autoResize étant actif par défaut, cela fonctionne tout seul en pratique.
Persistance. Pour un état récupérable — une position de défilement, une région sélectionnée, une caméra de carte — faites renvoyer par l'outil un identifiant stable et servez-vous-en comme clé de localStorage :
// Côté serveur, dans le callback de l'outil
return {
content: [{ type: "text", text: summary }],
structuredContent: payload,
_meta: { viewUUID: randomUUID() },
};// Côté vue
app.ontoolresult = (result) => {
const viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
const saved = viewUUID ? localStorage.getItem(viewUUID) : null;
if (saved) restore(JSON.parse(saved));
};Pour un état qui représente un véritable effort de l'utilisateur — annotations, configurations enregistrées, fichiers téléversés — n'utilisez pas localStorage. Persistez côté serveur via un outil réservé à l'app, rattaché à ce même identifiant de vue.
Modes d'affichage. Déclarez ce que vous prenez en charge dans appCapabilities.availableDisplayModes à l'initialisation, puis appelez app.requestDisplayMode() quand l'utilisateur clique sur votre bouton d'agrandissement. Un tableau de bord devrait déclarer ["inline", "fullscreen"] ; un lecteur vidéo pourrait ajouter pip. N'affichez le contrôle que si hostContext.availableDisplayModes indique que l'hôte peut l'honorer.
Tester votre implémentation
Construisez et démarrez le serveur :
pnpm build
pnpm dev
# MCP server listening on http://localhost:3001/mcpLa boucle de retour la plus rapide est l'hôte de référence du dépôt ext-apps, qui affiche votre vue dans un navigateur avec un accès complet aux DevTools :
git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps && npm install
cd examples/basic-host && npm start
# ouvrez http://localhost:8080Sélectionnez get-revenue dans la liste déroulante des outils, cliquez sur Call Tool, et votre tableau de bord s'affiche dans le bac à sable en dessous. Comme il s'agit d'une vraie iframe dans un vrai navigateur, console.log et les points d'arrêt fonctionnent normalement.
Pour tester dans un véritable assistant via stdio, pointez le client vers votre point d'entrée compilé :
{
"mcpServers": {
"revenue": {
"command": "node",
"args": ["/absolute/path/to/revenue-app/dist/index.js", "--stdio"]
}
}
}Demandez ensuite à l'assistant le chiffre d'affaires d'un trimestre et observez l'outil se déclencher.
Dépannage
L'outil s'exécute mais aucune UI n'apparaît. Le resourceUri du _meta de l'outil ne correspond à aucune ressource enregistrée, ou l'hôte ne prend pas en charge l'extension Apps. Vérifiez d'abord l'égalité exacte des chaînes — c'est la panne la plus fréquente, et elle est silencieuse par conception, le repli textuel étant le comportement spécifié.
La vue s'affiche vide. Votre bundle n'est pas en fichier unique. Vérifiez que dist/mcp-app.html contient le contenu des scripts intégré plutôt que des attributs src pointant vers des fichiers voisins. Les références d'actifs externes se résolvent contre l'origine du bac à sable, pas votre serveur, et échouent.
La vue se charge mais ne reçoit jamais de données. ontoolresult a été affecté après connect(). Déplacez-le dans onAppCreated.
Les requêtes réseau échouent avec une erreur CSP. L'origine n'est pas dans connectDomains. Rappelez-vous que les sous-domaines exigent un joker explicite : https://*.example.com.
Les polices et les couleurs paraissent fausses. Vous n'avez pas appelé useHostStyleVariables, ou vous avez stylé avec des valeurs en dur au lieu de var(--color-*).
Tout fonctionne en local et casse dans un hôte précis. Vérifiez si cet hôte exige une origine de bac à sable dédiée via _meta.ui.domain. Claude et ChatGPT emploient des formats différents — sous-domaines dérivés d'un hachage pour l'un, de l'URL pour l'autre — et la spécification indique explicitement que ce point dépend de l'hôte.
Notes de sécurité à lire deux fois
MCP Apps a été conçue avec un modèle de menace clair, et construire correctement dessus suppose de comprendre qui protège quoi.
Votre vue s'exécute dans une iframe sur une origine différente de celle de l'hôte, enveloppée par un proxy de bac à sable disposant uniquement de allow-scripts et allow-same-origin. Elle ne peut pas lire la conversation, toucher au DOM de l'hôte, ni joindre le réseau au-delà des origines que vous avez déclarées. Tout ce qu'elle demande passe par du JSON-RPC auditable, et les hôtes peuvent exiger une approbation explicite de l'utilisateur avant de relayer un appel d'outil. Des gabarits prédéclarés signifient qu'un hôte peut examiner à la connexion, et non à l'exécution, toutes les interfaces qu'un serveur pourrait afficher.
Ce que cela ne protège pas, c'est un serveur compromis ou un auteur de serveur malveillant — c'est-à-dire vous. Votre vue s'exécute avec les privilèges de votre serveur et peut appeler vos outils. Validez les arguments côté serveur exactement comme pour un outil appelé par le modèle ; une requête venant de votre propre interface n'est pas plus digne de confiance qu'une requête du modèle. Et n'intégrez jamais de secrets dans le bundle HTML : le document entier est lisible par quiconque peut appeler resources/read.
Pour aller plus loin
- Ajoutez une vraie bibliothèque de graphiques, mais vérifiez d'abord la taille du bundle — le document HTML entier voyage dans un message JSON-RPC à chaque affichage
- Explorez les serveurs d'exemple officiels : un globe CesiumJS, une scène Three.js, un moteur de shaders en temps réel, une carte thermique de cohortes, un lecteur PDF à chargement par morceaux
- Vous migrez une application ChatGPT existante ? Le
_meta["openai/outputTemplate"]de l'Apps SDK correspond à_meta.ui.resourceUri, et le dépôt fournit une agent skill qui effectue la conversion - Associez ce guide aux serveurs MCP sans état si vous déployez en serverless, car une vue peut survivre à une requête isolée
- Vous voulez plutôt exposer des outils côté navigateur qu'une UI côté serveur ? WebMCP résout le problème symétrique
Conclusion
MCP Apps reprend les deux primitives que vous connaissez déjà — outils et ressources — et ajoute exactement un champ de métadonnées pour les relier. Cette sobriété explique pourquoi elle s'est imposée comme la première extension officielle du protocole plutôt que comme un standard concurrent : rien ne change dans votre serveur existant, et les hôtes non compatibles se rabattent automatiquement sur le texte.
Les habitudes qui comptent sont petites et précises. Gardez content court et mettez la charge utile dans structuredContent. Enregistrez vos gestionnaires dans onAppCreated, jamais après la connexion. Déclarez chaque origine externe — ou mieux, n'en déclarez aucune. Stylez avec les variables de l'hôte. Dites au modèle ce que fait l'utilisateur via updateModelContext au lieu d'espérer qu'il devine.
Faites cela correctement, et votre serveur cessera de raconter les données pour commencer à les montrer.