Volver al Diario
12 min de lectura

Construyendo MCP UI: Widgets Interactivos Dirigidos por el Servidor para Agentes de IA

Arquitectura de interfaz generativa dirigida por el servidor sobre Model Context Protocol (MCP) mediante esquemas JSON estructurados, renderizado aislado en el cliente y bucles de acción bidireccionales.

MCPAI AgentsServer-Driven UIReactTypeScriptArchitecture
Construyendo MCP UI: Widgets Interactivos Dirigidos por el Servidor para Agentes de IA

El Model Context Protocol (MCP) resolvió un problema clave en los agentes de IA: brindó un estándar universal y limpio para conectar modelos con bases de datos, herramientas locales y APIs remotas a través de JSON-RPC 2.0. Se acabaron los adaptadores propietarios para cada proveedor.

Pero existe un cuello de botella evidente: las llamadas a herramientas MCP estándar devuelven texto plano o cadenas JSON en bruto.

Cuando un agente consulta una base de conocimientos interna, analiza trazas de despliegue o busca datos complejos, volcar texto sin formato en el chat genera una mala experiencia. No puedes filtrar registros con un clic, no puedes abrir un panel de detalles y, para realizar una acción de seguimiento, tienes que redactar otra consulta para que el modelo adivine los parámetros.

MCP UI resuelve esto combinando Server-Driven UI (SDUI) con servidores MCP. En lugar de volcados de texto, el servidor devuelve esquemas de componentes tipados y acciones interactivas. El frontend los renderiza como widgets de React interactivos con un bucle de retroalimentación directo hacia el entorno del agente.

Loading interactive sandbox...

Veamos cómo funciona esto de principio a fin: desde el diseño del protocolo de transporte y servidores TypeScript hasta renderizadores seguros en React y límites de aislamiento.


Arquitectura: El Bucle de Eventos Tri-Capa de MCP UI

En una integración estándar de MCP, el host del cliente actúa únicamente como un puente pasivo que transfiere cadenas de texto entre el LLM y el servidor MCP.

Con MCP UI, el host asume dos nuevas responsabilidades: un Registro de Componentes (que asigna identificadores a componentes React auditados) y un Despachador de Acciones (que conecta las interacciones del widget directamente con llamadas a herramientas posteriores sin necesidad de pasar de nuevo por el 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 │◄─┴──────┘
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

El ciclo de vida transcurre en cinco fases deterministas:

  1. Ejecución de Intención: El agente activa una herramienta MCP (discover_knowledge_node) con parámetros de búsqueda o identificadores de entidad.
  2. Serialización de Carga Dual: El servidor MCP genera dos cargas: un respaldo en texto plano markdown para clientes de terminal/headless y un esquema JSON estructurado con tipo application/vnd.mcp.ui+json.
  3. Resolución en Registro: El host frontend recibe el resultado, valida el esquema con Zod y localiza el componente solicitado en el registro local.
  4. Montaje Aislado: El host monta el componente, inyecta las props tipadas y conecta los controladores de retorno.
  5. Retorno Bidireccional: Cuando el usuario hace clic en un botón de acción (como filtrar por etiqueta o previsualizar un resumen), el componente despacha la acción a través de la capa cliente de MCP, ejecutando la siguiente herramienta sin requerir re-preguntas en lenguaje natural.

Wire Specification: The MCP UI Protocol Contract

Para mantener compatibilidad total con la especificación oficial de Model Context Protocol, las cargas de UI se encapsulan dentro de los resultados estándar de herramientas MCP utilizando representaciones de recursos estructurados.

The JSON-RPC 2.0 Wire Format

Cuando una herramienta MCP genera un componente de interfaz interactivo, completa el arreglo content con una definición de interfaz tipada:

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

Utilizando Zod, definimos el contrato estructural estricto para todos los componentes de interfaz emitidos por el servidor:

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

Implementemos un servidor Node.js de producción utilizando @modelcontextprotocol/sdk. Este servidor expone la herramienta discover_knowledge_node, que devuelve tanto texto legible como la carga estructurada 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

En el frontend (por ejemplo, dentro de una aplicación Next.js App Router), construimos un host de renderizado que:

  1. Analiza las respuestas de herramientas MCP y busca recursos application/vnd.mcp.ui+json.
  2. Compara el nombre del componente con un registro de componentes React fuertemente tipado.
  3. Despacha eventos de interacción del usuario directamente al transporte 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

Permitir que servidores MCP externos o remotos devuelvan definiciones de interfaz introduce riesgos potenciales de seguridad si no se diseña a la defensiva. Establecemos tres capas de seguridad:

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)

Nunca permita que un servidor MCP devuelva cadenas JSX sin procesar, funciones JavaScript o marcado HTML directo. Todas las salidas de interfaz deben ser esquemas JSON puramente declarativos verificados mediante Zod. El host del cliente vincula identificadores conocidos (BlogDiscoveryCard, MetricGrid) a componentes React auditados internamente.

2. Styling Containment via Shadow DOM

Para evitar que las propiedades de interfaz de un servidor MCP inyecten reglas CSS maliciosas (como superposiciones fijas para clickjacking o capas invisibles), envuelva los renderizadores del cliente en una raíz Shadow DOM:

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

Al admitir servidores MCP de terceros o comunitarios con diseños personalizados, aísle el contexto de renderizado dentro de un elemento <iframe> configurado con:

HTML
<iframe
  sandbox="allow-scripts"
  srcdoc="<!DOCTYPE html><html><body><div id='root'></div></body></html>"
  title="MCP UI Isolated Container"
/>

Al omitir intencionadamente allow-same-origin, el componente enmarcado se ejecuta en un origen opaco, impidiendo el acceso al almacenamiento local de la aplicación host, cookies de sesión, tokens o el DOM principal.


Headless and Terminal Fallbacks

Un requisito operativo fundamental para los servidores MCP es la resiliencia de protocolo: un servidor MCP debe seguir funcionando cuando se conecta a herramientas de línea de comandos, ejecutores de CI automatizados o entornos IDE que carecen de capacidades de renderizado gráfico.

Para soportar ambos entornos sin bifurcar la lógica del servidor, cada implementación de herramienta puede construir una respuesta dual:

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

Si un cliente no implementa application/vnd.mcp.ui+json, procesa únicamente la entrada text, proporcionando al usuario información en formato markdown sin errores.


Los Compromisos de Ingeniería: Cuándo Usar MCP UI (y Cuándo No)

Como todo patrón arquitectónico, Server-Driven UI introduce compromisos reales de ingeniería:

Engineering Dimension Text-Only MCP Tools MCP UI (Server-Driven UI)
Interaction Latency Alta (Requiere un ciclo completo de inferencia LLM para cada clic o filtro) Instantánea (< 50ms de despacho de eventos en cliente directo a la herramienta)
Client Surface Complexity Baja (Renderizador de markdown genérico) Moderada (Requiere mantenimiento de registro, validación de esquemas y enrutamiento de acciones)
Error Surface Mínima (Errores de generación de texto) Requiere versionado de esquemas y tarjetas de respaldo para componentes no registrados
Security Surface Riesgos estándar de inyección de prompts Requiere esquemas declarativos estrictos, contención de estilos CSS y aislamiento con iframes

Conclusión Práctica

Si el flujo de tu agente es puramente conversacional o se ejecuta en un batch autónomo sin interfaz, las herramientas de texto plano son suficientes. Pero si estás construyendo software asistido por agentes donde los humanos toman decisiones (aprobar pull requests, clasificar incidentes, filtrar datos o explorar bases de conocimiento), MCP UI transforma un tedioso intercambio de texto en una experiencia interactiva fluida.

Share this article