Building MCP UI: Server-Driven Interactive Widgets for AI Agents
Architecting server-driven generative UI over Model Context Protocol (MCP) using structured JSON schemas, client-side sandboxed rendering, and bidirectional action loops.

Model Context Protocol (MCP) did something overdue for AI agents: it gave us a clean, universal standard to connect models to databases, local shell tools, and remote APIs over JSON-RPC 2.0. No more rewriting custom tool bridges for every LLM provider.
There's just one glaring bottleneck: standard MCP tool calls return plain text or raw JSON strings.
If an agent queries an internal knowledge base, analyzes a deployment trace, or fetches a flight reservation, dumping a wall of raw text or markdown tables into a chat box is a terrible user experience. You can't click to filter records, you can't toggle a detail drawer, and if you want to take a follow-up action, you have to type another full sentence just so the model can guess the parameters for another tool call.
MCP UI fixes this by pairing Server-Driven UI (SDUI) with MCP servers. Instead of string dumps, your server returns typed component schemas and action payloads. Your frontend renders them as real, interactive React widgets with a direct feedback loop back to the agent runtime.
Let's walk through how this works end-to-end, covering wire protocol design, TypeScript server implementations, sandboxed React host renderers, and security boundaries.
Architecture: The Tri-Layer MCP UI Event Loop
In a stock MCP setup, the frontend host is just a dumb pipe passing strings between the LLM and the MCP server.
With MCP UI, the client host gets two new responsibilities: a Component Registry (mapping component IDs to audited React components) and an Action Dispatcher (wiring up widget clicks directly to downstream tool calls without round-tripping through the 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 │◄─┴──────┘
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘Here is how the lifecycle flows in practice:
- Tool Invocation: The agent calls
discover_knowledge_nodewith a search topic or entity ID. - Dual-Payload Response: The MCP server returns two representations: a markdown text fallback for CLI agents and a typed
application/vnd.mcp.ui+jsonresource. - Client Schema Validation: The host intercepts the resource, validates the props with Zod, and checks its local registry for a matching component (
BlogDiscoveryCard). - Sandboxed Mounting: The host mounts the component, injects props, and attaches action callbacks.
- Direct Action Dispatch: When a user clicks an action button (like filtering by tag or expanding an abstract), the client dispatches an action callback directly to the MCP transport layer. No prompt re-engineering needed.
Wire Specification: The MCP UI Protocol Contract
We don't need to break or fork the MCP standard to send UI components over the wire. The Model Context Protocol specification already supports embedding custom resources inside tool call results.
By packaging our UI payload inside a resource with the MIME type application/vnd.mcp.ui+json, compliant hosts render the component, while standard CLI tools fall back to the text field.
The JSON-RPC 2.0 Wire Frame
When an MCP tool yields an interactive UI component, it populates the content array with a MIME-typed 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
Using Zod, we define the strict structural contract for all server-emitted UI components:
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
Let's write a practical Node.js MCP server using the official @modelcontextprotocol/sdk. This server exposes discover_knowledge_node, which searches our knowledge base and emits both human-readable text and our BlogDiscoveryCard UI definition.
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 Host: The Component Registry & Action Bridge
On the frontend (like a Next.js chat interface), we need a dynamic host renderer that catches UI payloads, matches them against safe local components, and binds user clicks to tool dispatchers.
1. 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,
};2. 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: How to Keep 3rd-Party MCP Servers from Pwning Your UI
If your app only connects to MCP servers you wrote yourself, security is straightforward. But the moment you let users plug in community or third-party MCP servers, returning UI components opens up serious attack vectors: style injection, clickjacking, and XSS.
We use a three-tier defense-in-depth model to stay safe:
┌──────────────────────────────────────────────────────────────────────────┐
│ 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)
Never let an MCP server return raw JSX strings, JavaScript functions, or unsanitized HTML markup. All UI outputs must be purely declarative JSON schemas verified against Zod. The client host maps known string identifiers (BlogDiscoveryCard, MetricGrid) to internally maintained, audited React components.
2. Styling Containment via Shadow DOM
To prevent third-party MCP UI props from injecting CSS (such as fixed overlays or invisible capture layers), wrap client-side renderers in a 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
When supporting community or untrusted third-party MCP servers with custom layouts, isolate the entire rendering context within an <iframe> configured with:
<iframe
sandbox="allow-scripts"
srcdoc="<!DOCTYPE html><html><body><div id='root'></div></body></html>"
title="MCP UI Isolated Container"
/>By intentionally omitting allow-same-origin, the framed component runs in an opaque origin, preventing access to the host application's storage, session cookies, tokens, or parent DOM.
Terminal and Headless Fallbacks: Don't Break CLI Users
An MCP server shouldn't assume it's always running inside a shiny web browser. If someone hooks up your MCP server to a terminal agent or a headless CI pipeline, the server needs to degrade gracefully without throwing JSON errors.
Here's how to structure a clean dual response:
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),
},
},
],
};
}If a client does not implement application/vnd.mcp.ui+json, it processes only the text entry, giving the user markdown feedback without errors.
The Trade-offs: When to Use MCP UI (and When Not To)
Like any architectural pattern, Server-Driven UI introduces real engineering trade-offs:
| Engineering Dimension | Text-Only MCP Tools | MCP UI (Server-Driven UI) |
|---|---|---|
| Interaction Latency | High (Requires a full LLM roundtrip for every button click or filter) | Instant (< 50ms client-side event dispatch directly to tool) |
| Client Complexity | Low (Render markdown strings into standard HTML) | Moderate (Requires registry maintenance, schema validation, and action routing) |
| Error Handling | Minimal (Handles text generation failures) | Requires schema versioning and fallback cards for unmapped components |
| Security Surface | Standard prompt injection risks | Requires strict declarative schemas, CSS containment, and iframe isolation |
Bottom Line
If your agent workflow is purely conversational or runs in an autonomous headless batch, plain text tools are plenty. But if you're building agent-driven software where humans make decisions (approving pull requests, triaging incident alerts, filtering datasets, or exploring technical knowledge), MCP UI turns clumsy text back-and-forth into an app-grade interactive experience.