MCP UI Entwickeln: Server-Gesteuerte Interaktive Widgets für KI-Agenten
Architektur für server-gesteuerte generative Benutzeroberflächen über Model Context Protocol (MCP) mit strukturierten JSON-Schemas, isoliertem Client-Rendering und bidirektionalen Aktionsschleifen.

Das Model Context Protocol (MCP) hat ein lange bestehendes Problem gelöst: einen sauberen, universellen Standard für die Anbindung von Modellen an Datenbanken, lokale Shell-Tools und Remote-APIs über JSON-RPC 2.0. Keine proprietären Adapter mehr für jeden Provider.
Es gibt jedoch einen entscheidenden Engpass: Standard-MCP-Toolaufrufe liefern reinen Text oder unstrukturierte JSON-Strings zurück.
Wenn ein Agent interne Wissensdatenbanken abfragt, Deployment-Traces analysiert oder Buchungsdaten abruft, ist das Ausgeben von reinem Fließtext im Chat eine schlechte Nutzererfahrung. Man kann Datensätze nicht anklicken, keine Detail-Schubladen öffnen und für jede Folgeaktion muss erneut ein ganzer Satz eingetippt werden.
MCP UI löst dies durch die Kopplung von Server-Driven UI (SDUI) mit MCP-Servern. Statt reiner Textwüsten liefert der Server typisierte Komponentenschemas und Aktions-Payloads. Das Frontend rendert diese als interaktive React-Widgets mit direktem Rückkanal zur Agenten-Laufzeit.
Schauen wir uns an, wie das in der Praxis funktioniert – vom Transportprotokoll über TypeScript-Server bis hin zu isolierten React-Hosts und Sicherheitsgrenzen.
Architektur: Der dreistufige MCP-UI-Ereigniszyklus
In einem Standard-MCP-Setup fungiert der Client-Host lediglich als passive Datenleitung zwischen LLM und MCP-Server.
Mit MCP UI übernimmt der Host zwei neue Kernaufgaben: eine Component Registry (die Komponenten-IDs geprüften React-Komponenten zuordnet) und einen Action Dispatcher (der Widget-Klicks direkt mit Tool-Aufrufen verknüpft, ohne das LLM erneut zu belasten).
┌──────────────────────────────────────────────────────────────────┐
│ 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 │◄─┴──────┘
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘Der Lebenszyklus gliedert sich in fünf deterministische Phasen:
- Ausführung der Absicht: Der Agent ruft ein MCP-Tool (
discover_knowledge_node) mit Suchbegriffen oder Entitäts-Identifikatoren auf. - Duale Payload-Serialisierung: Der MCP-Server erzeugt zwei Payloads: ein Text-Fallback für Terminal-/Headless-Clients und ein strukturiertes JSON-Schema des Typs
application/vnd.mcp.ui+json. - Registry-Auflösung: Der Frontend-Host empfängt das Tool-Ergebnis, validiert die Signatur mit Zod und ermittelt die angeforderte Komponente aus der lokalen Registry.
- Isolierte Montage: Der Host bindet die Komponente ein, injiziert typisierte Props und verknüpft die Callback-Handler.
- Bidirektionaler Callback: Klickt der Benutzer auf eine Aktionsschaltfläche (z. B. Tag-Filterung oder Vorschau eines Abstracts), leitet die Komponente eine Aktion über den MCP-Client weiter und führt das Folgewerkzeug ohne erneute Text-Prompts aus.
Wire Specification: The MCP UI Protocol Contract
Um die vollständige Rückwärtskompatibilität mit der offiziellen Model Context Protocol Spezifikation zu gewährleisten, werden UI-Payloads innerhalb standardisierter MCP-Tool-Ergebnisse über strukturierte Ressourcenrepräsentationen gekapselt.
The JSON-RPC 2.0 Wire Format
Erzeugt ein MCP-Tool eine interaktive UI-Komponente, füllt es das content-Array mit einer typisierten UI-Definition:
{
"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
Mit Zod definieren wir den strukturellen Vertrag für alle vom Server generierten UI-Komponenten:
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
Implementieren wir einen Node.js-MCP-Server mit @modelcontextprotocol/sdk. Dieser Server stellt das Tool discover_knowledge_node bereit, das lesbaren Text und die strukturierte BlogDiscoveryCard-Komponenten-Payload liefert.
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
Auf dem Frontend (beispielsweise in einer Next.js App Router Anwendung) entwickeln wir einen Host-Renderer, der:
- MCP-Tool-Antworten parst und nach
application/vnd.mcp.ui+json-Ressourcen sucht. - Den Komponentennamen mit einer typisierten React-Component-Registry abgleicht.
- Benutzerinteraktions-Events direkt an den MCP-Transport zurückleitet.
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
Die Ausführung von UI-Definitionen externer oder nicht vertrauenswürdiger MCP-Server birgt Sicherheitsrisiken ohne defensive Architektur. Wir etablieren drei Schutzebenen:
┌──────────────────────────────────────────────────────────────────────────┐
│ 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)
Erlauben Sie einem MCP-Server niemals die Rückgabe von ungesicherten JSX-Strings, JavaScript-Funktionen oder HTML-Markup. Alle UI-Ausgaben müssen rein deklarative JSON-Schemas sein, die per Zod validiert werden. Der Client-Host verknüpft bekannte Bezeichner (BlogDiscoveryCard, MetricGrid) ausschließlich mit intern geprüften React-Komponenten.
2. Styling Containment via Shadow DOM
Um zu verhindern, dass externe UI-Definitionen unerwünschte CSS-Regeln einschleusen (wie bösartige Overlays für Clickjacking), kapseln wir Client-Renderer in einem 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
Bei der Einbindung von Drittanbieter-Plugins mit individuellen Layouts wird der gesamte Rendering-Kontext in einem <iframe> isoliert:
<iframe
sandbox="allow-scripts"
srcdoc="<!DOCTYPE html><html><body><div id='root'></div></body></html>"
title="MCP UI Isolated Container"
/>Durch den bewussten Verzicht auf allow-same-origin operiert das iframe in einem opaken Origin und hat keinen Zugriff auf Cookies, Tokens, LocalStorage oder das Eltern-DOM der Anwendung.
Headless and Terminal Fallbacks
Eine essenzielle betriebliche Anforderung für MCP-Server ist Protokoll-Resilienz: Ein Server muss funktionsfähig bleiben, wenn er über Terminal-Tools, CI-Pipelines oder IDEs ohne grafische UI-Ausgabe aufgerufen wird.
Um beide Umgebungen ohne Server-Verzweigungen zu unterstützen, baut jede Tool-Implementierung eine duale Antwort auf:
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),
},
},
],
};
}Unterstützt ein Client application/vnd.mcp.ui+json nicht, liest er nur das text-Element und liefert dem Entwickler fehlerfreies Markdown-Feedback.
Die Architektur-Kompromisse: Wann MCP UI sinnvoll ist (und wann nicht)
Wie jedes Architekturmuster bringt auch Server-Driven UI klare Vor- und Nachteile mit sich:
| Engineering Dimension | Text-Only MCP Tools | MCP UI (Server-Driven UI) |
|---|---|---|
| Interaction Latency | Hoch (Erfordert einen vollen LLM-Zyklus für jeden Klick oder Filter) | Sofortig (< 50ms Ereignis-Dispatch direkt zum Tool) |
| Client Surface Complexity | Niedrig (Einfaches Markdown-Rendering) | Moderat (Erfordert Registry-Pflege, Schema-Validierung und Action-Routing) |
| Error Surface | Minimal (Standard Textgenerierungsfehler) | Erfordert Schema-Versionierung und Fallback-Karten für nicht registrierte Komponenten |
| Security Surface | Standardmäßige Prompt-Injection-Risiken | Erfordert strikt deklarative Schemas, CSS-Kapselung und iframe-Isolierung |
Fazit für die Praxis
Wenn der Workflow eines Agenten rein textbasiert ist oder autonom im Hintergrund läuft, reichen reine Text-Tools völlig aus. Wenn Sie jedoch Anwendungen bauen, in denen Menschen Entscheidungen treffen (Pull Requests freigeben, Alarme triagieren oder Daten analysieren), verwandelt MCP UI mühsamen Textaustausch in ein interaktives Produkt-Erlebnis.