Ajouter une UI à un serveur MCP
Enrichissez les outils d'un serveur MCP existant avec des interfaces utilisateur interactives en utilisant le SDK MCP Apps (@modelcontextprotocol/ext-apps).
Comment ça marche
Les outils existants sont associés à des ressources HTML qui s'affichent en ligne dans la conversation de l'hôte. L'outil continue de fonctionner pour les clients texte uniquement — l'UI est une amélioration, pas un remplacement. Chaque outil qui bénéficie d'une UI est lié à une ressource via _meta.ui.resourceUri, et l'hôte affiche cette ressource dans une iframe en sandbox quand l'outil est appelé.
Obtenir le code de référence
Clonez le dépôt du SDK pour accéder aux 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, getUiCapability, options de visibilité des outils |
src/spec.types.ts |
Toutes les définitions de types : McpUiHostContext, clés de variables CSS, modes d'affichage |
src/styles.ts |
applyDocumentTheme, applyHostStyleVariables, applyHostFonts |
src/react/useApp.tsx |
Hook useApp pour les apps React |
src/react/useHostStyles.ts |
Hooks useHostStyles, useHostStyleVariables, useHostFonts |
Exemples clés (patterns d'outils mixtes)
Ces exemples montrent des serveurs avec des outils App-améliorés et des outils simples — exactement le pattern que vous ajoutez :
| Exemple | Pattern |
|---|---|
examples/map-server/ |
show-map (outil App) + geocode (outil simple) |
examples/pdf-server/ |
display_pdf (outil App) + list_pdfs (outil simple) + read_pdf_bytes (outil app-only) |
examples/system-monitor-server/ |
get-system-info (outil App) + poll-system-stats (outil de polling app-only) |
Templates de frameworks
Apprenez et adaptez /tmp/mcp-ext-apps/examples/basic-server-{framework}/ :
| Template | 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 le 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 |
Étape 1 : Analyser les outils existants
Avant d'écrire du code, analysez les outils existants du serveur et déterminez lesquels bénéficient d'une UI.
- Lisez la source du serveur et listez tous les outils enregistrés
- Pour chaque outil, évaluez s'il bénéficierait d'une UI (retourne des données qui pourraient être visualisées, implique une interaction utilisateur, etc.) ou s'il convient en texte seul (recherches simples, fonctions utilitaires)
- Identifiez les outils qui pourraient devenir des helpers app-only (données que l'UI doit interroger/récupérer mais que le modèle n'a pas besoin d'appeler directement)
- Présentez l'analyse à l'utilisateur et confirmez les outils à améliorer
Framework de décision
| Type de sortie d'outil | Bénéfice UI | Exemple |
|---|---|---|
| Données structurées / listes / tableaux | Élevé — tableau interactif, recherche, filtrage | Liste d'éléments, résultats de recherche |
| Métriques / nombres dans le temps | Élevé — graphiques, jauges, tableaux de bord | Statistiques système, analytiques |
| Média / contenu riche | Élevé — visionneuse, lecteur, renderer | Cartes, PDFs, images, vidéos |
| Texte simple / confirmations | Faible — le texte suffit | « Fichier créé », « Paramètre mis à jour » |
| Données pour d'autres outils | Considérer app-only | Points de sondage, chargeurs de chunks |
Étape 2 : Ajouter des dépendances
npm install @modelcontextprotocol/ext-apps
npm install -D vite vite-plugin-singlefile
Plus les dépendances spécifiques au framework si nécessaire (p. ex. react, react-dom, @vitejs/plugin-react pour React).
Utilisez npm install pour ajouter des 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.
Étape 3 : Configurer le pipeline de build
Configuration Vite
Créez vite.config.ts avec vite-plugin-singlefile pour bundler l'UI dans un seul fichier HTML :
import { defineConfig } from 'vite'
import { viteSingleFile } from 'vite-plugin-singlefile'
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: 'dist',
rollupOptions: {
input: 'mcp-app.html', // un par UI, ou une entrée partagée
},
},
})
Point d'entrée HTML
Créez mcp-app.html (ou un par UI distincte si les outils ont besoin de vues différentes) :
<!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/mcp-app.ts"></script>
</body>
</html>
Scripts de build
Ajoutez des scripts de build à package.json. L'UI doit être construite avant que le code du serveur ne la bundlise :
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}
Étape 4 : Convertir les outils en outils App
Transformez les outils MCP simples en outils App avec UI.
Avant (outil MCP simple) :
server.tool('my-tool', { param: z.string() }, async (args) => {
const data = await fetchData(args.param)
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
})
Après (outil App avec UI) :
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
} from '@modelcontextprotocol/ext-apps/server'
const resourceUri = 'ui://my-tool/mcp-app.html'
registerAppTool(
server,
'my-tool',
{
description: 'Shows data with an interactive UI',
inputSchema: { param: z.string() },
_meta: { ui: { resourceUri } },
},
async (args) => {
const data = await fetchData(args.param)
return {
content: [{ type: 'text', text: JSON.stringify(data) }], // text fallback for non-UI hosts
structuredContent: { data }, // structured data for the UI
}
}
)
Orientations clés :
- Gardez toujours le tableau
contentavec un texte de secours pour les clients texte uniquement - Ajoutez
structuredContentpour les données que l'UI doit afficher - Liez l'outil à sa ressource via
_meta.ui.resourceUri - Laissez les outils qui ne bénéficient pas d'une UI inchangés — ils restent des outils simples
Étape 5 : Enregistrer les ressources
Enregistrez la ressource HTML afin que l'hôte puisse la récupérer :
import fs from 'node:fs/promises'
import path from 'node:path'
const resourceUri = 'ui://my-tool/mcp-app.html'
registerAppResource(
server,
{
uri: resourceUri,
name: 'My Tool UI',
mimeType: RESOURCE_MIME_TYPE,
},
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 }] }
}
)
Si plusieurs outils partagent la même UI, ils peuvent référencer le même resourceUri et le même enregistrement de ressource.
Étape 6 : Construire l'UI
Enregistrement des handlers
Enregistrez TOUS les handlers AVANT d'appeler app.connect() :
import {
App,
PostMessageTransport,
applyDocumentTheme,
applyHostStyleVariables,
applyHostFonts,
} from '@modelcontextprotocol/ext-apps'
const app = new App({ name: 'My App', version: '1.0.0' })
app.ontoolinput = (params) => {
// Afficher l'UI en utilisant params.arguments et/ou params.structuredContent
}
app.ontoolresult = (result) => {
// Mettre à jour l'UI avec le résultat final de l'outil
}
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())
Styles de l'hôte
Utilisez les variables CSS de l'hôte pour l'intégration de thème :
.container {
background: var(--color-background-secondary);
color: var(--color-text-primary);
font-family: var(--font-sans);
border-radius: var(--border-radius-md);
}
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.
Pour les apps React, utilisez plutôt les hooks useApp et useHostStyles — voir basic-server-react/ pour le pattern.
Améliorations optionnelles
Outils helpers app-only
Des outils que l'UI appelle mais que le modèle n'a pas besoin d'invoquer directement (sondage, pagination, chargement de chunks) :
registerAppTool(
server,
'poll-data',
{
description: 'Polls 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 outils via app.callServerTool("poll-data", {}).
Configuration CSP
Si l'UI doit charger des ressources externes (polices, APIs, CDNs), déclarez les domaines :
registerAppResource(
server,
{
uri: resourceUri,
name: 'My Tool UI',
mimeType: RESOURCE_MIME_TYPE,
_meta: {
ui: {
connectDomains: ['api.example.com'], // cibles fetch/XHR
resourceDomains: ['cdn.example.com'], // scripts, styles, images
frameDomains: ['embed.example.com'], // iframes imbriquées
},
},
},
async () => {
/* ... */
}
)
Entrée partielle en streaming
Pour les grandes entrées d'outils, montrer la progression pendant la génération par le LLM :
app.ontoolinputpartial = (params) => {
const args = params.arguments // JSON partiel corrigé - toujours valide
// Afficher l'aperçu avec les données partielles
}
app.ontoolinput = (params) => {
// Entrée complète finale - basculer au rendu complet
}
Dégradation gracieuse avec getUiCapability()
Enregistrez conditionnellement les outils App seulement quand le client supporte l'UI, en basculant sur les outils texte uniquement :
import {
getUiCapability,
registerAppTool,
RESOURCE_MIME_TYPE,
} from '@modelcontextprotocol/ext-apps/server'
server.server.oninitialized = () => {
const clientCapabilities = server.server.getClientCapabilities()
const uiCap = getUiCapability(clientCapabilities)
if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) {
// Le client supporte l'UI — enregistrer l'outil App
registerAppTool(
server,
'my-tool',
{
description: 'Shows data with interactive UI',
_meta: { ui: { resourceUri } },
},
appToolHandler
)
} else {
// Client texte uniquement — enregistrer l'outil simple
server.tool('my-tool', 'Shows data', { param: z.string() }, plainToolHandler)
}
}
Mode plein écran
Permettez à l'UI de se développer en 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
}
Erreurs courantes à éviter
- Oublier le fallback texte
content— Incluez toujours le tableaucontentavec du texte pour les hôtes non-UI - Enregistrer les handlers après
connect()— Enregistrez TOUS les handlers AVANT d'appelerapp.connect() - Oublier
vite-plugin-singlefile— Sans lui, les assets ne se chargeront pas dans l'iframe sandboxée - Oublier l'enregistrement des ressources — L'outil référence un
resourceUriqui doit avoir une ressource correspondante - Hardcoder les styles — Utilisez les variables CSS de l'hôte (
var(--color-*)) pour l'intégration de thème - Ne pas gérer les safe area insets — Appliquez toujours
ctx.safeAreaInsetsdansonhostcontextchanged
Tests
Utiliser basic-host
Testez le serveur amélioré avec l'exemple basic-host :
# Terminal 1 : Construire et exécuter votre serveur
npm run build && npm run serve
# Terminal 2 : Exécuter basic-host (depuis le dépôt cloné)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# Ouvrir http://localhost:8080
Configurez SERVERS avec un tableau JSON d'URLs de votre serveur (défaut : http://localhost:3001/mcp).
Vérifier
- Les outils simples fonctionnent toujours et retournent une sortie texte
- Les outils App affichent leur UI dans l'iframe
- Le handler
ontoolinputse déclenche avec les arguments de l'outil - Le handler
ontoolresultse déclenche avec le résultat de l'outil - Les styles de l'hôte (thème, polices, couleurs) s'appliquent correctement