Costruire MCP UI: Widget Interattivi Guidati dal Server per Agenti AI
Architettura di interfaccia generativa guidata dal server su Model Context Protocol (MCP) mediante schemi JSON strutturati, rendering isolato nel client e cicli di azione bidirezionali.

Il Model Context Protocol (MCP) ha risolto un problema fondamentale per gli agenti IA: ha fornito uno standard universale e pulito per connettere i modelli a database, tool locali da terminale e API remote tramite JSON-RPC 2.0. Niente più adattatori proprietari per ogni provider.
C'è solo un collo di bottiglia evidente: i tool MCP standard restituiscono unicamente testo semplice o stringhe JSON grezze.
Quando un agente interroga una knowledge base interna, esamina i log di deployment o estrae dati complessi, riversare testo non formattato in una chat offre un'esperienza utente scadente. Non è possibile filtrare i record con un clic, aprire un pannello dei dettagli o eseguire un'azione successiva senza dover digitare un'altra frase affinché il modello ne indovini i parametri.
MCP UI risolve questo problema integrando i principi del Server-Driven UI (SDUI) con i server MCP. Invece di semplici stringhe di testo, il server restituisce schemi di componenti tipizzati e payload di azione. Il frontend li renderizza come veri widget React interattivi con un ciclo di eventi diretto verso il runtime dell'agente.
Vediamo come funziona l'architettura end-to-end: dalla progettazione del protocollo di trasporto e dei server TypeScript fino ai renderer host sicuri in React e ai confini di isolamento.
Architettura: Il Ciclo di Eventi a Tre Livelli di MCP UI
In un'integrazione MCP tradizionale, l'host client funge unicamente da ponte passivo per scambiare stringhe di testo tra l'LLM e il server MCP.
Con MCP UI, l'host assume due nuove responsabilità: un Component Registry (che mappa gli ID dei componenti a componenti React verificati) e un Action Dispatcher (che inoltra i clic del widget direttamente ai tool successivi senza passare nuovamente dall'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 │◄─┴──────┘
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘Il ciclo di vita si articola in cinque fasi deterministiche:
- Esecuzione dell'Intento: L'agente invoca un tool MCP (
discover_knowledge_node) con parametri di ricerca o identificatori di entità. - Serializzazione a Doppio Payload: Il server MCP genera due payload: un fallback testuale in markdown per client da riga di comando e uno schema JSON strutturato conforme a
application/vnd.mcp.ui+json. - Risoluzione nel Registro: L'host frontend riceve il risultato, valida lo schema tramite Zod e individua il componente nel registro locale.
- Montaggio Protetto: L'host monta il componente, inietta le props tipizzate e collega gli handler di callback.
- Callback Bidirezionale: Quando l'utente preme un pulsante di azione (come filtrare per tag o visualizzare l'estratto), il componente invia l'azione al layer client MCP, eseguendo il tool successivo senza richiedere nuove istruzioni testuali.
Wire Specification: The MCP UI Protocol Contract
Per mantenere la piena retrocompatibilità con la specifica ufficiale di Model Context Protocol, i payload della UI vengono incapsulati all'interno dei risultati dei tool MCP tramite risorse strutturate.
The JSON-RPC 2.0 Wire Format
Quando un tool MCP produce un componente interattivo, compila l'array content con una definizione di interfaccia tipizzata:
{
"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
Utilizzando Zod, definiamo il contratto strutturale per tutti i componenti emessi dal server:
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
Implementiamo un server Node.js di produzione utilizzando @modelcontextprotocol/sdk. Questo server espone il tool discover_knowledge_node, che restituisce testo esplicativo e il payload strutturato del componente BlogDiscoveryCard.
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
Sul frontend (ad esempio all'interno di un'applicazione Next.js App Router), realizziamo un motore host che:
- Analizza le risposte dei tool MCP e intercetta le risorse
application/vnd.mcp.ui+json. - Mappa il nome del componente su un registro di componenti React tipizzato.
- Invia gli eventi di interazione dell'utente direttamente al canale di trasporto MCP.
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
Consentire a server MCP remoti o di terze parti di trasmettere definizioni di interfaccia introduce potenziali vettori di rischio senza una progettazione difensiva. Implementiamo tre livelli di protezione:
┌──────────────────────────────────────────────────────────────────────────┐
│ 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)
Non permettere mai a un server MCP di inviare codice JSX grezzo, funzioni JavaScript o markup HTML diretto. Tutte le risposte dell'interfaccia devono essere schemi JSON puramente dichiarativi verificati con Zod. L'applicazione host mappa identificatori noti (BlogDiscoveryCard, MetricGrid) a componenti React verificati internamente.
2. Styling Containment via Shadow DOM
Per impedire che proprietà di interfaccia esterne iniettino regole CSS indesiderate (come overlay invisibili per il clickjacking), racchiudiamo i renderer in una 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
Quando si integrano plugin di terze parti con layout personalizzati, isoliamo l'intero contesto di rendering all'interno di un <iframe>:
<iframe
sandbox="allow-scripts"
srcdoc="<!DOCTYPE html><html><body><div id='root'></div></body></html>"
title="MCP UI Isolated Container"
/>Omettendo intenzionalmente allow-same-origin, l'iframe viene eseguito in un'origine opaca, impedendo l'accesso ai cookie di sessione, storage locale, token o al DOM principale.
Headless and Terminal Fallbacks
Un requisito operativo fondamentale per i server MCP è la resilienza del protocollo: un server deve rimanere pienamente utilizzabile da terminale, pipeline di CI o ambienti di sviluppo privi di supporto grafico.
Per garantire la compatibilità con entrambi i contesti, ogni tool restituisce una risposta duale:
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),
},
},
],
};
}Se un client non supporta application/vnd.mcp.ui+json, elabora unicamente il testo e fornisce all'utente un output markdown chiaro e privo di errori.
I Compromessi Architetturali: Quando Usare MCP UI (e Quando Evitarlo)
Come ogni pattern architetturale, il Server-Driven UI introduce precisi compromessi ingegneristici:
| Engineering Dimension | Text-Only MCP Tools | MCP UI (Server-Driven UI) |
|---|---|---|
| Interaction Latency | Elevata (Richiede un ciclo LLM completo per ogni clic o filtro) | Istantanea (< 50ms di invio evento lato client direttamente al tool) |
| Client Surface Complexity | Bassa (Semplice interprete markdown) | Moderata (Richiede gestione del registro, validazione degli schemi e routing delle azioni) |
| Error Surface | Minima (Errori di generazione testo) | Richiede versionamento degli schemi e card di fallback per componenti non registrati |
| Security Surface | Rischi standard di prompt injection | Richiede schemi dichiarativi rigorosi, contenimento CSS e isolamento iframe |
Conclusione Pratica
Se il flusso di lavoro del vostro agente è puramente conversazionale o viene eseguito in background in modo autonomo, i tool di solo testo sono più che sufficienti. Ma se state costruendo software guidato da agenti in cui gli esseri umani prendono decisioni (approvare pull request, gestire alert di incidenti o esplorare dati complessi), MCP UI trasforma un farraginoso scambio di testo in un'esperienza interattiva di livello applicativo.