Terug naar Journal
11 min leestijd

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.

MCPAI AgentsServer-Driven UIReactTypeScriptArchitecture
MCP UI Bouwen: Server-Gedreven Interactieve Widgets voor AI Agents

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.

Loading interactive sandbox...

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

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 │◄─┴──────┘
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

De levenscyclus doorloopt vijf deterministische fasen:

  1. Uitvoering van Intentie: De agent roept een MCP-tool aan (discover_knowledge_node) met zoekparameters of entiteits-ID's.
  2. 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.
  3. Registry-Resolutie: De frontend-host ontvangt het toolresultaat, valideert het schema met Zod en koppelt de gewenste component aan de lokale registry.
  4. Geïsoleerde Montage: De host monteert de component, injecteert getypeerde props en koppelt de callback-handlers.
  5. 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:

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

Met behulp van Zod definiëren we het strikte contract voor alle door de server gegenereerde UI-componenten:

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

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.

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

Aan de clientzijde (bijvoorbeeld in een Next.js App Router applicatie) bouwen we een host-renderer die:

  1. De antwoorden van MCP-tools parseert en zoekt naar application/vnd.mcp.ui+json-resources.
  2. De componentnaam koppelt aan een getypeerde React-componentenregistry.
  3. Gebruikersinteracties direct terugstuurt naar het MCP-transportkanaal.

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

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:

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)

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:

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

Bij het ondersteunen van externe plugins met aangepaste layouts isoleren we de volledige rendering-context binnen een <iframe>:

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

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),
        },
      },
    ],
  };
}

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.

Share this article