ServicesÀ proposNotesContact Me contacter →
EN FR
Note

Construire un serveur MCP Apps de visualisation

Comment construire un serveur MCP Apps de visualisation personnalisé en TypeScript — enregistrement des outils d'application avec métadonnées UI, service des ressources HTML, et implémentation du SDK client pour une communication bidirectionnelle.

Planté
mcpclaude codeaidata engineering

Lorsque les serveurs de visualisation existants ne couvrent pas vos besoins — types de graphiques personnalisés, interactions de drill-down complexes, structures de données propriétaires — vous construisez le vôtre. Cette note couvre l’implémentation : côté serveur TypeScript, SDK client, et les outils de build qui produisent un bundle déployable.

La configuration générale du serveur MCP est couverte dans MCP Server Project Setup. Cette note se concentre spécifiquement sur l’extension MCP Apps — le mécanisme de ressource ui:// et le SDK client qui rend l’UI interactive.

Prérequis

Node.js 18+ et deux packages au-delà du SDK MCP standard :

Terminal window
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps

Le package @modelcontextprotocol/ext-apps fournit registerAppTool, registerAppResource et RESOURCE_MIME_TYPE côté serveur, plus la classe App pour le bundle côté client.

Côté serveur : enregistrer l’outil d’application

Le serveur enregistre deux éléments : un outil avec des métadonnées UI, et une ressource qui sert le bundle HTML.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import * as fs from "fs/promises";
const server = new McpServer({ name: "Analytics Dashboard", version: "1.0.0" });
const resourceUri = "ui://dashboard/main.html";
// Enregistrer l'outil avec les métadonnées UI
registerAppTool(server, "show-dashboard", {
title: "Analytics Dashboard",
description: "Display interactive metrics dashboard. Use when the user asks for a visualization, chart, or dashboard of their data.",
inputSchema: {
type: "object",
properties: {
metrics: {
type: "array",
description: "Array of metric objects to display",
items: { type: "object" }
},
chartType: {
type: "string",
enum: ["bar", "line", "scatter", "pie"],
description: "Visualization type"
}
},
required: ["metrics"]
},
_meta: { ui: { resourceUri } }
}, async (args) => ({
content: [{ type: "text", text: JSON.stringify(args.metrics) }]
}));
// Enregistrer la ressource HTML
registerAppResource(server, resourceUri, resourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async () => {
const html = await fs.readFile("./dist/index.html", "utf-8");
return {
contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }]
};
}
);

Quelques points importants dans ce pattern :

Le gestionnaire d’outil retourne des données comme un bloc de contenu texte. La couche UI gère le rendu — la fonction de l’outil ne génère pas de HTML. Cette séparation garde la logique serveur propre et testable.

_meta.ui.resourceUri dans le schéma de l’outil est ce qui déclenche le comportement App dans l’hôte. Sans lui, l’outil est simplement un outil MCP standard. Avec lui, l’hôte sait qu’il doit précharger et rendre la ressource ui://.

Le gestionnaire de ressource lit le fichier HTML à l’exécution. Cela signifie que vous pouvez mettre à jour le bundle UI sans modifier le code du serveur.

Côté client : le SDK App

À l’intérieur du bundle HTML, la classe App de @modelcontextprotocol/ext-apps gère la communication avec l’hôte. Le bundle doit l’importer et configurer les gestionnaires d’événements avant d’appeler app.connect().

import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "Dashboard App", version: "1.0.0" });
// Reçoit le payload de données initial quand l'outil s'exécute
app.ontoolresult = (result) => {
const rawText = result.content?.find(c => c.type === "text")?.text;
if (!rawText) return;
const data = JSON.parse(rawText);
renderChart(data);
};
// Appelé depuis les interactions UI — changements de filtre, drill-downs, sélections de segment
async function loadSegmentDetails(segment: string) {
const result = await app.callServerTool({
name: "get-segment-details",
arguments: { segment }
});
const details = JSON.parse(result.content?.[0]?.text ?? "{}");
updateVisualization(details);
}
// Informe l'IA de ce que l'utilisateur vient de faire — permet des questions de suivi contextuelles
function onUserFilter(dimension: string, value: string) {
app.updateModelContext({
action: "filtered",
dimension,
value,
timestamp: new Date().toISOString()
});
}
app.connect();

L’appel app.callServerTool() est ce qui permet une véritable interactivité — cliquer sur une barre dans votre graphique rappelle le serveur pour récupérer des données de détail, qui restituent la visualisation. C’est le modèle d’interaction qui différencie MCP Apps d’un export de graphique statique.

app.updateModelContext() est optionnel mais vaut la peine d’être implémenté si les utilisateurs posent des questions de suivi après avoir interagi avec la visualisation. Sans lui, l’IA ne sait pas ce que l’utilisateur a fait dans l’UI ; avec lui, “pourquoi le mobile a-t-il chuté ?” arrive avec l’état du filtre déjà attaché.

Outils de build

L’hôte nécessite un seul fichier HTML autonome. Aucun lien CDN externe, aucun fichier de style ou de script séparé — tout est intégré. Vite avec vite-plugin-singlefile gère cela :

vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
// S'assurer que la sortie est adaptée à un usage inline
assetsInlineLimit: Infinity,
},
});
Terminal window
npm add -D vite vite-plugin-singlefile
npm run build
# dist/index.html est votre bundle déployable

Le SDK MCP Apps fournit des modèles de démarrage pour React, Vue, Svelte, Preact, Solid et vanilla JS. Pour la visualisation de données spécifiquement, React s’associe bien avec des bibliothèques comme Recharts ou Nivo ; vanilla JS avec Chart.js ou D3 est plus léger et évite la surcharge du framework dans le bundle.

Ajouter un outil de drill-down

Les serveurs de visualisation personnalisés ont souvent besoin de plus d’un outil — l’outil d’affichage principal et un ou plusieurs outils pour récupérer des données de détail. Enregistrez des outils supplémentaires sans _meta (ce sont des outils MCP ordinaires, pas des outils App) :

server.tool("get-segment-details", {
description: "Fetch detailed breakdown data for a specific segment. Called when user drills down into a chart segment.",
inputSchema: {
type: "object",
properties: {
segment: { type: "string", description: "Segment identifier to detail" },
dateRange: {
type: "object",
properties: {
start: { type: "string" },
end: { type: "string" }
}
}
},
required: ["segment"]
}
}, async (args) => {
// Interroger votre source de données
const data = await fetchSegmentDetails(args.segment, args.dateRange);
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});

Ces outils secondaires n’ont pas besoin de métadonnées UI car ils sont appelés depuis le SDK App (app.callServerTool()), pas directement par l’IA.

Structure du projet

my-viz-server/
├── package.json
├── tsconfig.json
├── server.ts # Serveur MCP — enregistrement des outils, gestionnaire de ressource
├── vite.config.ts # Configuration de build en fichier unique
├── src/
│ ├── main.ts # Configuration SDK App, gestionnaires d'événements
│ ├── chart.ts # Logique de visualisation
│ └── App.tsx # Composant React (si React est utilisé)
└── dist/
└── index.html # Bundle compilé — servi comme ressource ui://

Le serveur et le client sont dans le même référentiel car ils doivent rester synchronisés — le format de sortie de l’outil du serveur doit correspondre à ce que le client s’attend à recevoir via ontoolresult. Une incompatibilité de version entre ce que l’outil retourne et ce que le client peut analyser est une source courante d’échecs silencieux.

Tests

Testez en couches :

  1. Tests unitaires des gestionnaires d’outils directement — appelez les fonctions async avec des données de test, vérifiez que la sortie JSON est conforme à vos attentes.
  2. Testez le bundle de manière isolée — ouvrez dist/index.html dans un navigateur et injectez des événements ontoolresult fictifs. Vérifiez le rendu sans avoir besoin d’un serveur MCP actif.
  3. Tests d’intégration avec MCP Inspectornpx @modelcontextprotocol/inspector node server.js. Vérifiez que l’outil apparaît, que la ressource se charge, et que l’invocation basique de l’outil fonctionne.
  4. Testez dans le client réel — connectez à Claude Desktop ou Claude Code. Vérifiez que l’UI se rend dans la conversation et que l’interactivité fonctionne.

La note MCP Server Testing and Debugging couvre le workflow de test en trois étapes complet. Pour les Apps spécifiquement, ne sautez pas le test dans le client — le sandbox iframe et le canal de communication postMessage introduisent des modes d’échec que l’Inspector ne détecte pas.