convert-web-app

Par tldraw · tldraw

Ce skill doit être utilisé lorsque l'utilisateur demande à « ajouter le support MCP App à mon application web », « transformer mon application web en MCP App hybride », « faire fonctionner ma page web comme une MCP App », « encapsuler mon interface existante comme une MCP App », « convertir un embed iframe en MCP App », « transformer mon SPA en MCP App », ou a besoin d'ajouter le support MCP App à une application web existante tout en la conservant fonctionnelle en mode standalone. Fournit des conseils pour analyser les applications web existantes et créer une web + MCP App hybride avec enregistrement côté serveur des tools et des ressources.

npx skills add https://github.com/tldraw/tldraw --skill convert-web-app

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

  1. Sources de données — Comment l'application obtient-elle ses données ? (params URL, appels API, props, données codées en dur, localStorage)
  2. Dépendances externes — Scripts CDN, polices, endpoints API, iframes imbriquées, connexions WebSocket
  3. Système de build — Bundler actuel (Webpack, Vite, Rollup, aucun), framework (React, Vue, vanilla), points d'entrée
  4. Interactions utilisateur — L'application a-t-elle des entrées/formulaires qui devraient se mapper aux paramètres d'outil ?
  5. 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 :

  1. Compilez l'application en utilisant la commande de build existante
  2. 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)
  3. 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
  4. 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

  1. Vite empaquète l'UI → dist/mcp-app.html (fichier unique avec tous les ressources en ligne)
  2. 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

  1. Oublier les déclarations CSP pour les origines externes — échoue silencieusement dans l'iframe en bac à sable
  2. Utiliser localStorage / sessionStorage en mode MCP — non disponible dans l'iframe en bac à sable ; utilisez des fallbacks ou passez via structuredContent
  3. Oublier vite-plugin-singlefile — les ressources externes ne chargeront pas dans l'iframe
  4. Enregistrer les handlers après connect() — enregistrez TOUS les handlers AVANT d'appeler app.connect()
  5. 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
  6. Ne pas gérer les insets de zone sûre — appliquez toujours ctx.safeAreaInsets dans onhostcontextchanged
  7. Oublier le fallback texte content — fournissez toujours le tableau content pour les hôtes non-UI
  8. Oublier l'enregistrement de ressource — l'outil référence un resourceUri qui doit avoir une ressource correspondante
  9. 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

  1. Mode MCP : L'application charge dans basic-host sans erreurs de console
  2. Le handler ontoolinput se déclenche avec les arguments de l'outil
  3. Le handler ontoolresult se déclenche avec le résultat de l'outil
  4. Le style d'hôte (thème, polices, couleurs) s'applique correctement
  5. Les ressources externes se chargent (si les domaines CSP sont configurés)
  6. Mode autonome : L'application fonctionne toujours quand elle est ouverte directement dans un navigateur

Skills similaires