Ajouter le support MCP App à une application web
Ajoutez le support MCP App à une application web existante pour qu'elle fonctionne à la fois en tant qu'application web autonome et en tant que MCP App qui s'affiche en ligne dans les hôtes compatibles MCP comme Claude Desktop — à partir d'une seule base de code.
Fonctionnement
L'application web existante reste intacte. Une fine couche d'initialisation détecte si l'application s'exécute dans un hôte MCP ou comme une page web ordinaire, et récupère les paramètres de la source appropriée. Un nouveau serveur MCP enveloppe le HTML fourni de l'application en tant que ressource et enregistre un outil pour l'afficher.
Autonome : Le navigateur charge la page → L'application lit les params URL / APIs → affiche
MCP App : L'hôte appelle l'outil → Le serveur renvoie un résultat → L'hôte affiche l'application dans une iframe → L'application lit le cycle de vie MCP → affiche
La logique de rendu de l'application est partagée — seule la source de données change.
Obtenir le code de référence
Clonez le référentiel SDK pour les exemples fonctionnels et la documentation API :
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
Référence API (fichiers sources)
Lisez la documentation JSDoc directement depuis /tmp/mcp-ext-apps/src/ :
| Fichier | Contenu |
|---|---|
src/app.ts |
Classe App, handlers (ontoolinput, ontoolresult, onhostcontextchanged, onteardown), cycle de vie |
src/server/index.ts |
registerAppTool, registerAppResource, options de visibilité d'outil |
src/spec.types.ts |
Toutes les définitions de type : McpUiHostContext, clés de variables CSS, modes d'affichage |
src/styles.ts |
applyDocumentTheme, applyHostStyleVariables, applyHostFonts |
src/react/useApp.tsx |
Hook useApp pour applications React |
src/react/useHostStyles.ts |
Hooks useHostStyles, useHostStyleVariables, useHostFonts |
Modèles de framework
Apprenez et adaptez-vous depuis /tmp/mcp-ext-apps/examples/basic-server-{framework}/ :
| Modèle | Fichiers clés |
|---|---|
basic-server-vanillajs/ |
server.ts, src/mcp-app.ts, mcp-app.html |
basic-server-react/ |
server.ts, src/mcp-app.tsx (utilise hook useApp) |
basic-server-vue/ |
server.ts, src/App.vue |
basic-server-svelte/ |
server.ts, src/App.svelte |
basic-server-preact/ |
server.ts, src/mcp-app.tsx |
basic-server-solid/ |
server.ts, src/mcp-app.tsx |
Exemples de référence
| Exemple | Motif pertinent |
|---|---|
examples/map-server/ |
Intégration d'API externe + CSP (connectDomains, resourceDomains) |
examples/sheet-music-server/ |
Bibliothèque qui charge des ressources externes (soundfonts) |
examples/pdf-server/ |
Gestion du contenu binaire + outils helpers réservés à l'application |
Étape 1 : Analyser l'application web existante
Avant d'écrire du code, examinez l'application web existante pour planifier les modifications nécessaires.
Ce qu'il faut vérifier
- Sources de données — Comment l'application obtient-elle ses données ? (params URL, appels API, props, données codées en dur, localStorage)
- Dépendances externes — Scripts CDN, polices, endpoints API, iframes imbriquées, connexions WebSocket
- Système de build — Bundler actuel (Webpack, Vite, Rollup, aucun), framework (React, Vue, vanilla), points d'entrée
- Interactions utilisateur — L'application a-t-elle des entrées/formulaires qui devraient se mapper aux paramètres d'outil ?
- Détection du runtime — Comment déterminer si l'application s'exécute dans un hôte MCP (p. ex., vérifier l'origine actuelle, un param de requête, ou si
window.parent !== window)
Présentez les conclusions à l'utilisateur et confirmez l'approche.
Mappage des sources de données
En mode hybride, l'application conserve ses sources de données existantes pour l'utilisation autonome et en ajoute des équivalents MCP :
| Source de données autonome | Équivalent MCP App |
|---|---|
| Paramètres de requête URL | ontoolinput / ontoolresult arguments ou structuredContent |
| Appels API REST | app.callServerTool() vers les outils côté serveur, ou conserver les appels API directs avec CSP connectDomains |
| Props / entrées de composants | ontoolinput arguments |
| localStorage / sessionStorage | Non disponible dans l'iframe en bac à sable — passer via structuredContent ou état côté serveur |
| Connexions WebSocket | Conserver avec CSP connectDomains, ou convertir en polling via outils réservés à l'application |
| Données codées en dur | Déplacer vers structuredContent de l'outil pour les rendre dynamiques |
Étape 2 : Enquêter sur les exigences CSP
Le HTML des MCP Apps s'exécute dans une iframe en bac à sable sans serveur de même origine. Chaque origine externe doit être déclarée dans CSP — les origines manquantes échouent silencieusement.
Avant d'écrire du code, compilez l'application et enquêtez sur toutes les origines qu'elle référence :
- Compilez l'application en utilisant la commande de build existante
- Recherchez dans le HTML, CSS et JS résultants chaque origine (pas seulement les origines « externes » — chaque requête réseau aura besoin d'une approbation CSP)
- Pour chaque origine trouvée, tracez jusqu'à la source :
- Si elle vient d'une constante → universelle (identique en dev et prod)
- Si elle vient d'une variable d'env ou condition → notez le mécanisme et identifiez les deux valeurs dev et prod
- Vérifiez les bibliothèques tierces qui peuvent faire leurs propres requêtes (analytique, suivi d'erreurs, etc.)
Documentez vos conclusions sous la forme de trois listes, et notez pour chaque origine si elle est universelle, dev-only ou prod-only :
- resourceDomains : origines servant les images, polices, styles, scripts
- connectDomains : origines pour les requêtes API/fetch
- frameDomains : origines pour les iframes imbriquées
Si aucune origine n'est trouvée, l'application peut ne pas avoir besoin de domaines CSP personnalisés.
Étape 3 : Configurer le serveur MCP
Créez un nouveau serveur MCP avec l'enregistrement d'outil et de ressource. Cela enveloppe l'application web existante pour les hôtes MCP.
Dépendances
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
npm install -D tsx vite vite-plugin-singlefile
Utilisez npm install pour ajouter les dépendances plutôt que d'écrire manuellement les numéros de version. Cela permet à npm de résoudre les dernières versions compatibles. Ne spécifiez jamais les numéros de version de mémoire.
Code du serveur
Créez server.ts :
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
} from '@modelcontextprotocol/ext-apps/server'
import fs from 'node:fs/promises'
import path from 'node:path'
import { z } from 'zod'
const server = new McpServer({ name: 'my-app', version: '1.0.0' })
const resourceUri = 'ui://my-app/mcp-app.html'
// Enregistrez l'outil — inputSchema se map aux sources de données de l'application
registerAppTool(
server,
'show-app',
{
description: 'Displays the app with the given parameters',
inputSchema: { query: z.string().describe('The search query') },
_meta: { ui: { resourceUri } },
},
async (args) => {
// Traitez les args côté serveur si nécessaire
return {
content: [{ type: 'text', text: `Showing app for: ${args.query}` }],
structuredContent: { query: args.query },
}
}
)
// Enregistrez la ressource HTML
registerAppResource(
server,
{
uri: resourceUri,
name: 'My App UI',
mimeType: RESOURCE_MIME_TYPE,
// Ajoutez les domaines CSP de l'Étape 2 si nécessaire :
// _meta: { ui: { connectDomains: ["api.example.com"], resourceDomains: ["cdn.example.com"] } },
},
async () => {
const html = await fs.readFile(
path.resolve(import.meta.dirname, 'dist', 'mcp-app.html'),
'utf-8'
)
return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] }
}
)
// Démarrez le serveur
const transport = new StdioServerTransport()
await server.connect(transport)
Scripts de package
Ajoutez à package.json :
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}
Étape 4 : Adapter le pipeline de build
Le build MCP App doit produire un seul fichier HTML en utilisant vite-plugin-singlefile. Le build de l'application web autonome reste inchangé.
Configuration Vite
Créez ou mettez à jour vite.config.ts. Si l'application utilise déjà Vite, ajoutez vite-plugin-singlefile et un point d'entrée séparé pour le build MCP App. Si elle utilise un autre bundler, ajoutez une configuration Vite à côté pour le build MCP App uniquement.
import { defineConfig } from 'vite'
import { viteSingleFile } from 'vite-plugin-singlefile'
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: 'dist',
rollupOptions: {
input: 'mcp-app.html',
},
},
})
Ajoutez les plugins Vite spécifiques au framework selon les besoins (p. ex., @vitejs/plugin-react pour React, @vitejs/plugin-vue pour Vue).
Point d'entrée HTML
Créez mcp-app.html comme point d'entrée séparé pour le build MCP App. Cela peut pointer vers le même code d'application — la détection du runtime gère le reste :
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MCP App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
Build en deux phases
- Vite empaquète l'UI →
dist/mcp-app.html(fichier unique avec tous les ressources en ligne) - Le serveur est compilé séparément (TypeScript → JavaScript)
L'application web autonome continue de se compiler et se déployer comme avant.
Étape 5 : Ajouter l'initialisation MCP App aux côtés de la logique existante
C'est l'étape cruciale. Au lieu de remplacer les sources de données de l'application, ajoutez un chemin d'initialisation alternatif pour le mode MCP. L'application détecte son environnement au démarrage et lit les paramètres de la bonne source.
Le motif hybride
import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps'
// Détectez si nous exécutons dans un hôte MCP.
// Choisissez une méthode de détection qui correspond à l'application :
// - Vérification d'origine : window.location.origin !== 'https://myhost.com'
// - Origine nulle (iframe en bac à sable) : window.location.origin === 'null'
// - Param de requête : new URL(location.href).searchParams.has('mcp')
const isMcpApp = window.location.origin === 'null'
async function getParameters(): Promise<Record<string, string>> {
if (isMcpApp) {
// S'exécute comme MCP App — obtient les params du cycle de vie d'outil
const app = new App({ name: 'My App', version: '1.0.0' })
// Enregistrez les handlers AVANT connect()
const params = await new Promise<Record<string, string>>((resolve) => {
app.ontoolresult = (result) => resolve(result.structuredContent ?? {})
})
await app.connect(new PostMessageTransport())
return params
} else {
// S'exécute comme application web autonome — obtient les params depuis l'URL
return Object.fromEntries(new URL(location.href).searchParams)
}
}
async function main() {
const params = await getParameters()
renderApp(params) // Même logique de rendu pour les deux modes
}
main().catch(console.error)
Paramètres URL (Hybride)
// Avant (autonome uniquement) :
const query = new URL(location.href).searchParams.get('q')
renderApp(query)
// Après (hybride) :
async function getQuery(): Promise<string> {
if (isMcpApp) {
const app = new App({ name: 'My App', version: '1.0.0' })
return new Promise((resolve) => {
app.ontoolinput = (params) => resolve(params.arguments?.q ?? '')
app.connect(new PostMessageTransport())
})
}
return new URL(location.href).searchParams.get('q') ?? ''
}
const query = await getQuery()
renderApp(query) // Logique de rendu inchangée
Appels API (Hybride)
// Avant (autonome uniquement) :
const data = await fetch('/api/data').then((r) => r.json())
// Après (hybride) :
async function fetchData(): Promise<any> {
if (isMcpApp) {
const result = await app.callServerTool('fetch-data', {})
return result.structuredContent
}
return fetch('/api/data').then((r) => r.json())
}
Ou gardez les appels API directs dans les deux modes avec CSP connectDomains :
// Les appels API peuvent rester inchangés si l'API est externe et que le CSP déclare le domaine
// Déclarez connectDomains: ["api.example.com"] dans l'enregistrement de ressource
localStorage / sessionStorage (Hybride)
// Avant (autonome uniquement) :
const saved = localStorage.getItem('settings')
// Après (hybride) — localStorage n'est pas disponible dans les iframes en bac à sable :
function getSettings(): any {
if (isMcpApp) {
// Sera fourni via le résultat de l'outil
return null // ou une valeur par défaut
}
return JSON.parse(localStorage.getItem('settings') ?? 'null')
}
Exemple hybride complet
import {
App,
PostMessageTransport,
applyDocumentTheme,
applyHostStyleVariables,
applyHostFonts,
} from '@modelcontextprotocol/ext-apps'
const isMcpApp = window.location.origin === 'null'
async function initMcpApp(): Promise<Record<string, any>> {
const app = new App({ name: 'My App', version: '1.0.0' })
// Enregistrez TOUS les handlers AVANT connect()
const params = await new Promise<Record<string, any>>((resolve) => {
app.ontoolinput = (input) => resolve(input.arguments ?? {})
})
app.onhostcontextchanged = (ctx) => {
if (ctx.theme) applyDocumentTheme(ctx.theme)
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables)
if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts)
if (ctx.safeAreaInsets) {
const { top, right, bottom, left } = ctx.safeAreaInsets
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`
}
}
app.onteardown = async () => {
return {}
}
await app.connect(new PostMessageTransport())
return params
}
async function initStandaloneApp(): Promise<Record<string, any>> {
return Object.fromEntries(new URL(location.href).searchParams)
}
async function main() {
const params = isMcpApp ? await initMcpApp() : await initStandaloneApp()
renderApp(params) // Même logique de rendu — aucune bifurcation nécessaire
}
main().catch(console.error)
Étape 6 : Ajouter l'intégration du style d'hôte (mode MCP uniquement)
En s'exécutant comme MCP App, intégrez le style d'hôte pour la cohérence des thèmes. Utilisez des fallbacks de variables CSS pour que l'application s'affiche correctement dans les deux modes.
Vanilla JS — utilisez les fonctions helper :
import {
applyDocumentTheme,
applyHostStyleVariables,
applyHostFonts,
} from '@modelcontextprotocol/ext-apps'
app.onhostcontextchanged = (ctx) => {
if (ctx.theme) applyDocumentTheme(ctx.theme)
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables)
if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts)
}
React — utilisez les hooks :
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react'
const { app } = useApp({ appInfo, capabilities, onAppCreated })
useHostStyles(app)
Utiliser les variables en CSS — utilisez var() avec des fallbacks pour que le mode autonome ait aussi l'air correct :
.container {
background: var(--color-background-secondary, #f5f5f5);
color: var(--color-text-primary, #333);
font-family: var(--font-sans, system-ui);
border-radius: var(--border-radius-md, 8px);
}
Groupes de variables clés : --color-background-*, --color-text-*, --color-border-*, --font-sans, --font-mono, --font-text-*-size, --font-heading-*-size, --border-radius-*. Consultez src/spec.types.ts pour la liste complète.
Améliorations optionnelles
Outils helpers réservés à l'application
Pour les données que l'UI doit interroger ou récupérer que le modèle n'a pas besoin d'appeler directement :
registerAppTool(
server,
'refresh-data',
{
description: 'Fetches latest data for the UI',
_meta: { ui: { resourceUri, visibility: ['app'] } },
},
async () => {
const data = await getLatestData()
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
}
)
L'UI appelle ces via app.callServerTool("refresh-data", {}).
Entrée partielle en streaming
Pour les entrées d'outil volumineuses, utilisez ontoolinputpartial pour afficher la progression durant la génération LLM :
app.ontoolinputpartial = (params) => {
const args = params.arguments // JSON partiel corrigé - toujours valide
renderPreview(args)
}
app.ontoolinput = (params) => {
renderFull(params.arguments)
}
Mode plein écran
app.onhostcontextchanged = (ctx) => {
if (ctx.availableDisplayModes?.includes('fullscreen')) {
fullscreenBtn.style.display = 'block'
}
if (ctx.displayMode) {
container.classList.toggle('fullscreen', ctx.displayMode === 'fullscreen')
}
}
async function toggleFullscreen() {
const newMode = currentMode === 'fullscreen' ? 'inline' : 'fullscreen'
const result = await app.requestDisplayMode({ mode: newMode })
currentMode = result.mode
}
Fallback texte
Fournissez toujours un tableau content pour les hôtes non-UI :
return {
content: [{ type: 'text', text: 'Fallback description of the result' }],
structuredContent: {
/* données pour l'UI */
},
}
Erreurs courantes à éviter
- Oublier les déclarations CSP pour les origines externes — échoue silencieusement dans l'iframe en bac à sable
- Utiliser
localStorage/sessionStorageen mode MCP — non disponible dans l'iframe en bac à sable ; utilisez des fallbacks ou passez viastructuredContent - Oublier
vite-plugin-singlefile— les ressources externes ne chargeront pas dans l'iframe - Enregistrer les handlers après
connect()— enregistrez TOUS les handlers AVANT d'appelerapp.connect() - Coder les styles en dur sans fallbacks — utilisez les variables CSS d'hôte avec
var(..., fallback)pour que les deux modes s'affichent correctement - Ne pas gérer les insets de zone sûre — appliquez toujours
ctx.safeAreaInsetsdansonhostcontextchanged - Oublier le fallback texte
content— fournissez toujours le tableaucontentpour les hôtes non-UI - Oublier l'enregistrement de ressource — l'outil référence un
resourceUriqui doit avoir une ressource correspondante - Remplacer la logique autonome au lieu de la brancher — gardez les sources de données originales intactes ; ajoutez le chemin MCP à côté
Test
Utiliser basic-host
Testez le mode MCP App avec l'exemple basic-host :
# Terminal 1 : Compilez et exécutez votre serveur
npm run build && npm run serve
# Terminal 2 : Exécutez basic-host (depuis le repo cloné)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# Ouvrez http://localhost:8080
Configurez SERVERS avec un tableau JSON des URLs de votre serveur (par défaut : http://localhost:3001/mcp).
Vérifier
- Mode MCP : L'application charge dans basic-host sans erreurs de console
- Le handler
ontoolinputse déclenche avec les arguments de l'outil - Le handler
ontoolresultse déclenche avec le résultat de l'outil - Le style d'hôte (thème, polices, couleurs) s'applique correctement
- Les ressources externes se chargent (si les domaines CSP sont configurés)
- Mode autonome : L'application fonctionne toujours quand elle est ouverte directement dans un navigateur