Zurück zum Journal
11 Min. Lesezeit

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.

MCPAI AgentsServer-Driven UIReactTypeScriptArchitecture
MCP UI Entwickeln: Server-Gesteuerte Interaktive Widgets für KI-Agenten

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.

Loading interactive sandbox...

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).

TEXT
┌──────────────────────────────────────────────────────────────────┐
│                  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:

  1. Ausführung der Absicht: Der Agent ruft ein MCP-Tool (discover_knowledge_node) mit Suchbegriffen oder Entitäts-Identifikatoren auf.
  2. 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.
  3. 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.
  4. Isolierte Montage: Der Host bindet die Komponente ein, injiziert typisierte Props und verknüpft die Callback-Handler.
  5. 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:

JSON
{
  "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:

TYPESCRIPT
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.

TYPESCRIPT
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:

  1. MCP-Tool-Antworten parst und nach application/vnd.mcp.ui+json-Ressourcen sucht.
  2. Den Komponentennamen mit einer typisierten React-Component-Registry abgleicht.
  3. Benutzerinteraktions-Events direkt an den MCP-Transport zurückleitet.

The React Component Registry

TSX
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

TSX
"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 &apos;{parsedPayload.component}&apos; 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:

TEXT
┌──────────────────────────────────────────────────────────────────────────┐
│                             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:

TSX
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:

HTML
<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:

TYPESCRIPT
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.

Share this article