Concevoir MCP UI : Widgets Interactifs Pilotés par le Serveur pour Agents IA
Architecture d'interface générative pilotée par le serveur via Model Context Protocol (MCP) avec schémas JSON structurés, rendu client isolé et boucles d'action bidirectionnelles.

Le Model Context Protocol (MCP) a résolu un problème majeur pour les agents IA : offrir un standard universel et propre pour connecter les modèles aux bases de données, aux outils locaux et aux API distantes via JSON-RPC 2.0. Fini les adaptateurs propriétaires pour chaque fournisseur.
Mais un goulot d'étranglement persiste : les outils MCP standards renvoient uniquement du texte brut ou des chaînes JSON non formatées.
Lorsqu'un agent interroge une base de connaissances interne, analyse des traces de déploiement ou extrait des données complexes, déverser du texte brut dans une fenêtre de chat offre une expérience utilisateur médiocre. Impossible de filtrer des enregistrements en un clic, d'ouvrir un tiroir de détails ou de déclencher une action immédiate sans devoir rédiger une nouvelle phrase pour que le modèle devine les paramètres.
MCP UI résout ce problème en combinant le Server-Driven UI (SDUI) avec les serveurs MCP. Au lieu d'accumuler du texte, le serveur retourne des schémas de composants typés et des charges d'action. Le frontend les restitue sous forme de widgets React interactifs dotés d'une boucle d'événements directe vers l'environnement de l'agent.
Voyons comment mettre cela en œuvre de bout en bout : de la conception du protocole de transport et des serveurs TypeScript jusqu'aux hôtes React sécurisés et aux limites d'isolation.
Architecture : La Boucle d'Événements Tri-Couches de MCP UI
Dans une intégration MCP classique, le client hôte se contente de relayer passivement des chaînes de caractères entre le LLM et le serveur MCP.
Avec MCP UI, l'hôte assume deux nouvelles responsabilités : un Registre de Composants (associant des identifiants à des composants React audités) et un Gestionnaire d'Actions (reliant directement les clics sur les widgets à des outils sans repasser par une inférence LLM).
┌──────────────────────────────────────────────────────────────────┐
│ AI Agent Client Host (Next.js) │
│ │
│ ┌────────────────────┐ ┌───────────────────────────────┐ │
│ │ LLM Orchestrator │ │ Dynamic UI Renderer │ │
│ │ (Vercel AI SDK) │ │ │ │
│ └─────────┬──────────┘ │ ┌─────────────────────────┐ │ │
│ │ │ │ Component Cache │ │ │
│ │ tools/call │ └────────────┬────────────┘ │ │
│ ▼ │ ▼ │ │
│ ┌────────────────────┐ │ [ BlogDiscoveryCard ] │ │
│ │ MCP Client Layer │──────►│ [ Action Buttons ] │ │
│ │ (JSON-RPC Router) │◄──────│ │ │ │
│ └─────────┬──────────┘ └───────────────┼───────────────┘ │
│ │ │ │
│ │ JSON-RPC 2.0 (Stdio / SSE) │ │
└────────────┼──────────────────────────────────┼──────────────────┘
│ │
▼ │ UI Action Callback
┌────────────────────────────────────────┐ │ (tools/call: on_select)
│ Custom MCP Server │ │
│ │ │
│ ┌──────────────────────────────────┐ │ │
│ │ Tool: discover_knowledge_node │ │ │
│ │ ├─ Query Vector / Metadata Store │ │ │
│ │ └─ Generate UI Schema Definition │◄─┴──────┘
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘Le cycle d'exécution s'articule autour de cinq étapes déterministes :
- Exécution de l'Intention : L'agent appelle un outil MCP (
discover_knowledge_node) avec des paramètres de recherche ou des identifiants d'entités. - Sérialisation Double Payload : Le serveur MCP produit deux charges : un fallback textuel en markdown pour les clients en ligne de commande et un schéma JSON structuré conforme à
application/vnd.mcp.ui+json. - Résolution du Registre : L'hôte frontend reçoit le résultat, valide la signature du schéma avec Zod et résout le composant dans le registre local.
- Montage Isolé : L'hôte monte le composant, injecte les propriétés typées et associe les gestionnaires d'événements.
- Rappel Bidirectionnel : Dès que l'utilisateur clique sur un bouton d'action (comme filtrer par tag ou prévisualiser un résumé), le composant transmet l'action via le client MCP, exécutant l'outil consécutif sans nécessiter de relance en langage naturel.
Wire Specification: The MCP UI Protocol Contract
Afin de préserver une rétrocompatibilité absolue avec la spécification officielle du Model Context Protocol, les payloads d'interface sont intégrés dans les résultats d'outils MCP sous forme de représentations de ressources structurées.
The JSON-RPC 2.0 Wire Format
Lorsqu'un outil MCP produit un composant d'interface interactif, il renseigne le tableau content avec une définition d'interface typée :
{
"jsonrpc": "2.0",
"id": "req-9841",
"result": {
"content": [
{
"type": "text",
"text": "Found 1 matching article: 'Building MCP UI: Server-Driven Interactive Widgets for AI Agents'."
},
{
"type": "resource",
"resource": {
"uri": "ui://components/blog-discovery-card/mcp-ui-server-driven-interactive-components",
"mimeType": "application/vnd.mcp.ui+json",
"text": "{\"component\":\"BlogDiscoveryCard\",\"version\":\"1.0.0\",\"props\":{\"title\":\"Building MCP UI: Server-Driven Interactive Widgets for AI Agents\",\"slug\":\"mcp-ui-server-driven-interactive-components\",\"description\":\"Architecting server-driven generative UI over Model Context Protocol (MCP)...\",\"readingTime\":8,\"tags\":[\"MCP\",\"AI Agents\",\"Server-Driven UI\"],\"coverImage\":\"/images/blog/mcp-ui-server-driven-interactive-components-cover.png\"},\"actions\":[{\"id\":\"filter_tag\",\"label\":\"Filter by Tag\",\"tool\":\"discover_knowledge_node\",\"parameters\":{\"tag\":\"AI Agents\"}},{\"id\":\"preview_section\",\"label\":\"Read Abstract\",\"tool\":\"get_article_abstract\",\"parameters\":{\"slug\":\"mcp-ui-server-driven-interactive-components\"}}]}"
}
}
]
}
}TypeScript UI Schema Definition
Avec Zod, nous définissons le contrat structurel régissant l'ensemble des composants générés par le serveur :
import { z } from "zod";
export const McpUiActionSchema = z.object({
id: z.string(),
label: z.string(),
tool: z.string(),
parameters: z.record(z.unknown()),
variant: z.enum(["primary", "secondary", "danger"]).default("secondary"),
});
export const McpUiPayloadSchema = z.object({
component: z.string(),
version: z.string(),
props: z.record(z.unknown()),
actions: z.array(McpUiActionSchema).default([]),
});
export type McpUiAction = z.infer<typeof McpUiActionSchema>;
export type McpUiPayload = z.infer<typeof McpUiPayloadSchema>;Server-Side Implementation: Exposing MCP UI in TypeScript
Implémentons un serveur Node.js de production à l'aide de @modelcontextprotocol/sdk. Ce serveur expose l'outil discover_knowledge_node, renvoyant à la fois un texte explicatif et la payload structurée du composant BlogDiscoveryCard.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
interface KnowledgeArticle {
title: string;
slug: string;
description: string;
readingTime: number;
tags: string[];
coverImage: string;
}
const KNOWLEDGE_BASE: Record<string, KnowledgeArticle> = {
"mcp-ui-server-driven-interactive-components": {
title: "Building MCP UI: Server-Driven Interactive Widgets for AI Agents",
slug: "mcp-ui-server-driven-interactive-components",
description: "Architecting server-driven generative UI over Model Context Protocol using JSON schemas and sandboxed rendering.",
readingTime: 8,
tags: ["MCP", "AI Agents", "Server-Driven UI", "React"],
coverImage: "/images/blog/mcp-ui-server-driven-interactive-components-cover.png",
},
};
const server = new Server(
{
name: "knowledge-mcp-ui-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Register Tool Discovery
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "discover_knowledge_node",
description: "Search technical journal articles and return rich interactive cards.",
inputSchema: {
type: "object",
properties: {
topic: { type: "string", description: "Search query or keyword" },
tag: { type: "string", description: "Optional category tag filter" },
},
required: ["topic"],
},
},
],
};
});
// Handle Tool Execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== "discover_knowledge_node") {
throw new Error(`Unknown tool: ${request.params.name}`);
}
const args = request.params.arguments as { topic: string; tag?: string };
const matchedKey = Object.keys(KNOWLEDGE_BASE).find((key) =>
key.includes(args.topic.toLowerCase().replace(/\s+/g, "-"))
) || "mcp-ui-server-driven-interactive-components";
const article = KNOWLEDGE_BASE[matchedKey];
// Construct UI Component Payload
const uiPayload = {
component: "BlogDiscoveryCard",
version: "1.0.0",
props: {
title: article.title,
slug: article.slug,
description: article.description,
readingTime: article.readingTime,
tags: article.tags,
coverImage: article.coverImage,
},
actions: [
{
id: "filter_by_tag",
label: `More on ${article.tags[0]}`,
tool: "discover_knowledge_node",
parameters: { topic: article.tags[0], tag: article.tags[0] },
variant: "secondary" as const,
},
{
id: "open_reader",
label: "Open Full Post",
tool: "navigate_route",
parameters: { href: `/en/journal/${article.slug}` },
variant: "primary" as const,
},
],
};
return {
content: [
{
type: "text",
text: `Found article: "${article.title}" (${article.readingTime} min read).`,
},
{
type: "resource",
resource: {
uri: `ui://knowledge/${article.slug}`,
mimeType: "application/vnd.mcp.ui+json",
text: JSON.stringify(uiPayload),
},
},
],
};
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
process.stderr.write(`Server startup failed: ${err.message}\n`);
process.exit(1);
});Client-Side Host: Dynamic Component Registry & Action Bridge
Côté frontend (au sein d'une application Next.js App Router), nous développons un moteur hôte chargé de :
- Analyser les réponses des outils MCP à la recherche de ressources
application/vnd.mcp.ui+json. - Mapper le nom du composant sur un registre React fortement typé.
- Transmettre les événements d'interaction utilisateur directement au canal de transport MCP.
The React Component Registry
import React from "react";
import Image from "next/image";
import { McpUiAction } from "./types";
interface BlogDiscoveryCardProps {
title: string;
slug: string;
description: string;
readingTime: number;
tags: string[];
coverImage: string;
actions: McpUiAction[];
onAction: (action: McpUiAction) => void;
}
export function BlogDiscoveryCard({
title,
description,
readingTime,
tags,
coverImage,
actions,
onAction,
}: BlogDiscoveryCardProps) {
return (
<div style={{ border: "1px solid var(--border-color, #e2e8f0)", borderRadius: 8, padding: 16, background: "var(--surface, #ffffff)" }}>
{coverImage && (
<div style={{ position: "relative", width: "100%", height: 160, marginBottom: 12 }}>
<Image
src={coverImage}
alt={title}
fill
sizes="(max-width: 768px) 100vw, 400px"
style={{ objectFit: "cover", borderRadius: 4 }}
/>
</div>
)}
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
{tags.map((tag) => (
<span key={tag} style={{ fontSize: "0.75rem", background: "#f1f5f9", padding: "2px 8px", borderRadius: 4 }}>
{tag}
</span>
))}
<span style={{ fontSize: "0.75rem", color: "#64748b", marginLeft: "auto" }}>
{readingTime} min read
</span>
</div>
<h3 style={{ margin: "0 0 8px 0", fontSize: "1.1rem" }}>{title}</h3>
<p style={{ margin: "0 0 16px 0", fontSize: "0.875rem", color: "#475569" }}>{description}</p>
<div style={{ display: "flex", gap: 8 }}>
{actions.map((act) => (
<button
key={act.id}
onClick={() => onAction(act)}
style={{
padding: "6px 12px",
borderRadius: 4,
cursor: "pointer",
fontSize: "0.875rem",
background: act.variant === "primary" ? "#0f172a" : "#f8fafc",
color: act.variant === "primary" ? "#ffffff" : "#0f172a",
border: "1px solid #cbd5e1",
}}
>
{act.label}
</button>
))}
</div>
</div>
);
}
// Registry map
export const COMPONENT_REGISTRY: Record<string, React.ComponentType<any>> = {
BlogDiscoveryCard,
};The Dynamic Host Renderer
"use client";
import React from "react";
import { McpUiPayloadSchema, McpUiPayload, McpUiAction } from "./types";
import { COMPONENT_REGISTRY } from "./registry";
interface McpToolResource {
uri: string;
mimeType: string;
text: string;
}
interface McpToolResult {
content: Array<
| { type: "text"; text: string }
| { type: "resource"; resource: McpToolResource }
>;
}
interface McpUiHostProps {
toolResult: McpToolResult;
onDispatchTool: (toolName: string, params: Record<string, unknown>) => Promise<void>;
}
export function McpUiHost({ toolResult, onDispatchTool }: McpUiHostProps) {
// Extract UI resources
const uiResource = toolResult.content.find(
(c) => c.type === "resource" && c.resource.mimeType === "application/vnd.mcp.ui+json"
);
if (!uiResource || uiResource.type !== "resource") {
return null;
}
let parsedPayload: McpUiPayload;
try {
const rawJson = JSON.parse(uiResource.resource.text);
parsedPayload = McpUiPayloadSchema.parse(rawJson);
} catch {
return (
<div style={{ color: "#dc2626", fontSize: "0.875rem" }}>
Failed to validate MCP UI component payload.
</div>
);
}
const TargetComponent = COMPONENT_REGISTRY[parsedPayload.component];
if (!TargetComponent) {
return (
<div style={{ color: "#d97706", fontSize: "0.875rem" }}>
Unknown component '{parsedPayload.component}' requested by MCP server.
</div>
);
}
const handleAction = async (action: McpUiAction) => {
await onDispatchTool(action.tool, action.parameters);
};
return (
<div className="mcp-ui-host-container" style={{ margin: "16px 0" }}>
<TargetComponent
{...parsedPayload.props}
actions={parsedPayload.actions}
onAction={handleAction}
/>
</div>
);
}Security Boundaries & Sandboxing Untrusted Servers
Autoriser des serveurs MCP tiers ou distants à transmettre des définitions d'interface introduit des risques de sécurité majeurs en l'absence d'une architecture défensive. Nous mettons en œuvre trois couches de protection :
┌──────────────────────────────────────────────────────────────────────────┐
│ Defense-in-Depth │
│ │
│ 1. Strict Schema Whitelisting (No raw HTML / eval execution) │
│ 2. Shadow DOM CSS Isolation (Prevents CSS token override / injection) │
│ 3. Sandboxed Iframe Boundary for 3rd-Party Plugins │
│ (sandbox="allow-scripts", postMessage only) │
└──────────────────────────────────────────────────────────────────────────┘1. Schema-Driven Whitelisting (Zero Eval)
N'autorisez jamais un serveur MCP à retourner des chaînes JSX brutes, des fonctions JavaScript ou du balisage HTML direct. Toutes les réponses d'interface doivent être des schémas JSON strictement déclaratifs validés via Zod. L'application hôte associe les identifiants déclarés (BlogDiscoveryCard, MetricGrid) à des composants React audités en interne.
2. Styling Containment via Shadow DOM
Pour empêcher l'injection de règles CSS non sollicitées (comme des superpositions masquées pour le clickjacking), isolez les moteurs de rendu dans une racine Shadow DOM :
import React, { useRef, useEffect } from "react";
import ReactDOM from "react-dom/client";
export function ShadowDomBoundary({ children }: { children: React.ReactNode }) {
const mountRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<ReactDOM.Root | null>(null);
useEffect(() => {
if (!mountRef.current) return;
const shadow = mountRef.current.shadowRoot || mountRef.current.attachShadow({ mode: "open" });
if (!rootRef.current) {
rootRef.current = ReactDOM.createRoot(shadow);
}
rootRef.current.render(children);
return () => {
// Cleanup on unmount
};
}, [children]);
return <div ref={mountRef} />;
}3. Third-Party Plugin Isolation via Sandboxed Iframes
Pour les plugins tiers issus de la communauté avec des mises en page spécifiques, confinez le contexte d'exécution dans un élément <iframe> configuré avec :
<iframe
sandbox="allow-scripts"
srcdoc="<!DOCTYPE html><html><body><div id='root'></div></body></html>"
title="MCP UI Isolated Container"
/>En omettant délibérément allow-same-origin, l'iframe s'exécute sous une origine opaque, interdisant tout accès aux cookies de session, tokens, stockage local ou au DOM parent.
Headless and Terminal Fallbacks
La résilience du protocole est une exigence opérationnelle essentielle : un serveur MCP doit demeurer pleinement exploitable depuis un terminal, une chaîne CI/CD ou un IDE dépourvu d'interface graphique.
Pour couvrir ces deux environnements sans alourdir la logique applicative, chaque outil produit une réponse double :
import { McpUiPayload } from "./types";
interface KnowledgeArticle {
title: string;
slug: string;
description: string;
readingTime: number;
tags: string[];
}
export function buildDualMcpResponse(article: KnowledgeArticle, uiPayload: McpUiPayload) {
return {
content: [
// 1. Terminal / Markdown Fallback (Read by CLI agents)
{
type: "text",
text: [
`### ${article.title}`,
`*${article.description}*`,
`- Reading Time: ${article.readingTime} min`,
`- Tags: ${article.tags.join(", ")}`,
`- URL: https://damandeep.dev/en/journal/${article.slug}`,
].join("\n"),
},
// 2. Rich UI Resource (Rendered by GUI hosts)
{
type: "resource",
resource: {
uri: `ui://knowledge/${article.slug}`,
mimeType: "application/vnd.mcp.ui+json",
text: JSON.stringify(uiPayload),
},
},
],
};
}Si le client ne prend pas en charge le type application/vnd.mcp.ui+json, il lit uniquement le champ text et restitue le contenu au format markdown sans lever d'erreur.
Les Arbitrages Techniques : Quand Utiliser MCP UI (et Quand s'en Passer)
Comme tout modèle architectural, le Server-Driven UI implique des compromis d'ingénierie réels :
| Engineering Dimension | Text-Only MCP Tools | MCP UI (Server-Driven UI) |
|---|---|---|
| Interaction Latency | Élevée (Requiert un aller-retour LLM complet pour chaque clic ou filtre) | Instantanée (< 50ms d'envoi d'événement direct côté client) |
| Client Surface Complexity | Faible (Interpréteur markdown standard) | Modérée (Nécessite maintenance de registre, validation de schéma et routage d'actions) |
| Error Surface | Minimale (Erreurs de génération textuelle) | Requiert versionnage de schéma et cartes de repli pour composants non mappés |
| Security Surface | Risques standards d'injection de prompt | Requiert schémas déclaratifs stricts, confinement CSS et isolation par iframes |
Bilan Pratique
Si le flux de travail de votre agent est purement conversationnel ou s'exécute de façon autonome en arrière-plan, les outils en texte brut sont amplement suffisants. Mais si vous développez des logiciels assistés par agents où des humains prennent des décisions critiques (valider des pull requests, trier des alertes d'incidents ou explorer des bases de données), MCP UI transforme un échange textuel fastidieux en une expérience logicielle interactive et fluide.