Torna al Diario
12 min di lettura

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.

MCPAI AgentsServer-Driven UIReactTypeScriptArchitecture
Costruire MCP UI: Widget Interattivi Guidati dal Server per Agenti AI

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.

Loading interactive sandbox...

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

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

Il ciclo di vita si articola in cinque fasi deterministiche:

  1. Esecuzione dell'Intento: L'agente invoca un tool MCP (discover_knowledge_node) con parametri di ricerca o identificatori di entità.
  2. 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.
  3. Risoluzione nel Registro: L'host frontend riceve il risultato, valida lo schema tramite Zod e individua il componente nel registro locale.
  4. Montaggio Protetto: L'host monta il componente, inietta le props tipizzate e collega gli handler di callback.
  5. 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:

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

Utilizzando Zod, definiamo il contratto strutturale per tutti i componenti emessi dal server:

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

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.

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

Sul frontend (ad esempio all'interno di un'applicazione Next.js App Router), realizziamo un motore host che:

  1. Analizza le risposte dei tool MCP e intercetta le risorse application/vnd.mcp.ui+json.
  2. Mappa il nome del componente su un registro di componenti React tipizzato.
  3. Invia gli eventi di interazione dell'utente direttamente al canale di trasporto MCP.

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

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:

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)

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:

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

Quando si integrano plugin di terze parti con layout personalizzati, isoliamo l'intero contesto di rendering all'interno di un <iframe>:

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

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

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.

Share this article