MCP UI Bouwen: Server-Gedreven Interactieve Widgets voor AI Agents
Architectuur voor server-gedreven generatieve UI over Model Context Protocol (MCP) met gestructureerde JSON-schema's, geïsoleerde client-rendering en bidirectionele actielussen.

Het Model Context Protocol (MCP) bracht een broodnodige standaard voor AI-agents: een universele, schone manier om modellen via JSON-RPC 2.0 te koppelen aan databases, lokale CLI-tools en remote API's. Geen eindeloze maatwerk-adapters meer per provider.
Er is echter één groot knelpunt: standaard MCP-tools leveren uitsluitend platte tekst of ruwe JSON-strings op.
Wanneer een agent een interne kennisbank doorzoekt, deployment-traces analyseert of data opvraagt, is het dumpen van platte tekst in een chatvenster een gebrekkige gebruikerservaring. Je kunt records niet aanklikken, geen detailpanelen openen en voor elke vervolgactie moet je weer een nieuwe prompt uittypen zodat het model de parameters kan raden.
MCP UI lost dit op door Server-Driven UI (SDUI) te combineren met MCP-servers. In plaats van tekstwüsten stuurt je server getypeerde componentschema's en actie-payloads terug. De frontend rendert deze als interactieve React-widgets met een directe feedbacklus naar de agent-runtime.
Laten we stap voor stap bekijken hoe dit in elkaar zit: van wire-protocol en TypeScript-servers tot beveiligde React-hosts en sandbox-isolatie.
Architectuur: De Drielagige MCP UI Event-Loop
In een standaard MCP-opstelling is de frontend-host slechts een doorgeefluik voor tekst tussen het LLM en de MCP-server.
Met MCP UI krijgt de host twee nieuwe taken: een Component Registry (die component-ID's koppelt aan geteste React-componenten) en een Action Dispatcher (die interacties direct doorstuurt naar tools zonder tussenkomst van het 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 │◄─┴──────┘
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘De levenscyclus doorloopt vijf deterministische fasen:
- Uitvoering van Intentie: De agent roept een MCP-tool aan (
discover_knowledge_node) met zoekparameters of entiteits-ID's. - Duale Payload-Serialisatie: De MCP-server genereert twee payloads: een platte tekst markdown-fallback voor terminal-/headless-clients en een gestructureerd JSON-schema van het type
application/vnd.mcp.ui+json. - Registry-Resolutie: De frontend-host ontvangt het toolresultaat, valideert het schema met Zod en koppelt de gewenste component aan de lokale registry.
- Geïsoleerde Montage: De host monteert de component, injecteert getypeerde props en koppelt de callback-handlers.
- Bidirectionele Callback: Wanneer de gebruiker op een actieknop klikt (zoals filteren op tag of het bekijken van een samenvatting), stuurt de component de actie door via de MCP-clientlaag en voert de vervolgtool uit zonder nieuwe prompts.
Wire Specification: The MCP UI Protocol Contract
Om volledige achterwaartse compatibiliteit met de officiële Model Context Protocol specificatie te waarborgen, worden UI-payloads ingekapseld binnen standaard MCP-toolresultaten via gestructureerde resource-representaties.
The JSON-RPC 2.0 Wire Format
Wanneer een MCP-tool een interactieve UI-component produceert, vult deze de content-array met een getypeerde UI-definitie:
{
"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
Met behulp van Zod definiëren we het strikte contract voor alle door de server gegenereerde UI-componenten:
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
Laten we een productieklare Node.js MCP-server implementeren met behulp van @modelcontextprotocol/sdk. Deze server biedt de tool discover_knowledge_node, die zowel leesbare tekst als de gestructureerde BlogDiscoveryCard-payload retourneert.
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
Aan de clientzijde (bijvoorbeeld in een Next.js App Router applicatie) bouwen we een host-renderer die:
- De antwoorden van MCP-tools parseert en zoekt naar
application/vnd.mcp.ui+json-resources. - De componentnaam koppelt aan een getypeerde React-componentenregistry.
- Gebruikersinteracties direct terugstuurt naar het MCP-transportkanaal.
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
Het toestaan van externe of externe MCP-servers om UI-definities te retourneren brengt veiligheidsrisico's met zich mee als dit niet defensief wordt ontworpen. We hanteren drie beveiligingslagen:
┌──────────────────────────────────────────────────────────────────────────┐
│ 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)
Laat een MCP-server nooit ongecontroleerde JSX-strings, JavaScript-functies of HTML-markup retourneren. Alle UI-definities moeten strikt declaratieve JSON-schema's zijn die via Zod worden gevalideerd. De clienthost koppelt bekende identifiers (BlogDiscoveryCard, MetricGrid) uitsluitend aan intern gecontroleerde React-componenten.
2. Styling Containment via Shadow DOM
Om te voorkomen dat externe UI-definities ongewenste CSS-regels injecteren (zoals verborgen overlays voor clickjacking), wikkelen we client-renderers in een Shadow Root:
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
Bij het ondersteunen van externe plugins met aangepaste layouts isoleren we de volledige rendering-context binnen een <iframe>:
<iframe
sandbox="allow-scripts"
srcdoc="<!DOCTYPE html><html><body><div id='root'></div></body></html>"
title="MCP UI Isolated Container"
/>Door doelbewust allow-same-origin weg te laten, draait het iframe in een opake herkomst zonder toegang tot session tokens, cookies, lokale opslag of de hoofd-DOM.
Headless and Terminal Fallbacks
Een essentiële operationele vereiste voor MCP-servers is protocolresistentie: een server moet bruikbaar blijven vanaf de commandoregel, in geautomatiseerde CI-omgevingen of binnen IDE's zonder grafische weergave.
Om beide omgevingen te ondersteunen, construeert elke tool een gecombineerd antwoord:
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),
},
},
],
};
}Als een client application/vnd.mcp.ui+json niet ondersteunt, leest deze alleen het text-veld en toont de gebruiker foutloze markdown-terugkoppeling.
De Architectuur-Afwegingen: Wanneer MCP UI Gebruiken (en Wanneer Niet)
Zoals elk architectuurpatroon brengt Server-Driven UI reële afwegingen met zich mee:
| Engineering Dimension | Text-Only MCP Tools | MCP UI (Server-Driven UI) |
|---|---|---|
| Interaction Latency | Hoog (Vereist een volledige LLM-ronde voor elke klik of filter) | Direct (< 50ms gebeurtenisafhandeling direct naar de tool) |
| Client Surface Complexity | Laag (Standaard markdown-renderer) | Matig (Vereist registry-onderhoud, schemavalidatie en actierouting) |
| Error Surface | Minimaal (Tekstgeneratiefouten) | Vereist schemaversiebeheer en fallback-kaarten voor onbekende componenten |
| Security Surface | Standaard prompt-injectierisico's | Vereist strikt declaratieve schema's, CSS-isolatie en iframe-sandboxing |
Conclusie voor de Praktijk
Als de workflow van je agent puur tekstueel is of autonoom op de achtergrond draait, volstaan tools met platte tekst prima. Maar als je software bouwt waarin mensen beslissingen nemen (pull requests goedkeuren, incidenten analyseren of datasets doorzoeken), maakt MCP UI van een moeizame tekstuele dialoog een volwaardige, interactieve applicatie-ervaring.