Назад в журнал
11 мин чтения

Создание MCP UI: Серверные Интерактивные Виджеты для ИИ-Агентов

Архитектура генеративного серверного интерфейса поверх Model Context Protocol (MCP) с использованием типизированных JSON-схем, изолированного рендеринга на клиенте и двунаправленных циклов действий.

MCPAI AgentsServer-Driven UIReactTypeScriptArchitecture
Создание MCP UI: Серверные Интерактивные Виджеты для ИИ-Агентов

Протокол Model Context Protocol (MCP) решил давно назревшую задачу в разработке ИИ-агентов: дал единый открытый стандарт для подключения моделей к базам данных, локальным утилитам и внешним API через JSON-RPC 2.0. Больше никаких кастомных адаптеров под каждого провайдера.

Но оставалось узкое место: стандартные инструменты MCP возвращают исключительно сырой текст или неформатированные JSON-строки.

Когда агент запрашивает корпоративную базу знаний, исследует трейсы деплоя или ищет бронирования, вываливать стену текста в чат — крайне неудобно для пользователя. Нельзя отфильтровать список в один клик, нельзя развернуть карточку с деталями, а для любого следующего шага приходится снова формулировать полный текстовый запрос, чтобы модель угадала параметры следующего вызова.

MCP UI решает эту проблему за счет объединения Server-Driven UI (SDUI) с серверами MCP. Вместо простыней текста сервер отдает типизированные схемы компонентов и действий. Фронтенд безопасно рендерит их как полноценные интерактивные React-виджеты с прямым циклом обратной связи в среду исполнения агента.

Loading interactive sandbox...

Давайте разберем архитектуру от и до: от формата передачи данных и серверов на TypeScript до безопасных React-хостов и изоляции интерфейса.


Архитектура: Трехуровневый Цикл Событий MCP UI

В стандартной интеграции MCP хост-клиент работает как пассивный канал для пересылки строк между моделью и MCP-сервером.

В MCP UI на хост ложатся две новые задачи: Реестр Компонентов (сопоставляющий строковые идентификаторы с проверенными React-компонентами) и Диспетчер Действий (передающий клики по виджетам напрямую в вызовы инструментов без повторного прогона через 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 │◄─┴──────┘
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

Жизненный цикл включает пять шагов:

  1. Исполнение Намерения: Агент вызывает инструмент MCP (discover_knowledge_node) с параметрами поиска или идентификаторами сущностей.
  2. Сериализация Двойного Результата: Сервер MCP создает два ответа: текстовую markdown-версию для терминальных клиентов и структурированную JSON-схему типа application/vnd.mcp.ui+json.
  3. Разрешение в Реестре: Фронтенд-хост получает результат, валидирует схему с помощью Zod и сопоставляет компонент с локальным реестром.
  4. Изолированное Монтирование: Хост монтирует компонент, передает типизированные свойства и подключает обработчики событий.
  5. Двунаправленный Обратный Вызов: При нажатии пользователем кнопки действия (например, фильтрации по тегу или предпросмотра статьи) компонент отправляет команду через клиентский слой MCP, вызывая следующий инструмент без необходимости писать текстовый промпт.

Wire Specification: The MCP UI Protocol Contract

Для сохранения полной совместимости со спецификацией Model Context Protocol данные интерфейса упаковываются в стандартные результаты выполнения инструментов через структурированные ресурсы.

The JSON-RPC 2.0 Wire Format

Когда инструмент MCP возвращает интерактивный компонент интерфейса, массив content дополняется определением ресурса:

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

С помощью библиотеки Zod мы фиксируем строгий структурный контракт для всех генерируемых сервером компонентов:

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

Реализуем сервер Node.js с использованием @modelcontextprotocol/sdk. Данный сервер предоставляет инструмент discover_knowledge_node, возвращающий понятный текстовый ответ и структурированные данные для компонента 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

На стороне клиента (например, в приложении Next.js App Router) мы создаем хост-рендерер, выполняющий:

  1. Анализ ответов инструментов MCP на наличие ресурсов application/vnd.mcp.ui+json.
  2. Сопоставление имени компонента с типизированным реестром React-компонентов.
  3. Отправку событий взаимодействия пользователя обратно в транспортный уровень 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

Прием определений интерфейса от внешних или удаленных MCP-серверов создает потенциальные риски без эшелонированной защиты. Мы вводим три уровня безопасности:

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)

Никогда не разрешайте MCP-серверу возвращать сырые JSX-строки, JavaScript-функции или прямой HTML-код. Все структуры интерфейса должны быть строго декларативными JSON-схемами, проверяемыми Zod. Клиентский хост сопоставляет разрешенные идентификаторы (BlogDiscoveryCard, MetricGrid) только с проверенными внутри системы компонентами React.

2. Styling Containment via Shadow DOM

Для защиты от внедрения нежелательных CSS-правил (например, невидимых перекрывающих слоев) оборачивайте клиентские рендереры в 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

При подключении сторонних плагинов с произвольным оформлением изолируйте контекст отображения внутри тега <iframe>:

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

Отказ от директивы allow-same-origin изолирует фрейм в отдельном контексте, блокируя доступ к куки, сессионным токенам, локальному хранилищу и DOM основного приложения.


Headless and Terminal Fallbacks

Ключевым эксплуатационным требованием к MCP-серверам является отказоустойчивость протокола: сервер обязан корректно работать при вызовах из консоли, систем автосборки и сред разработки без графического интерфейса.

Для поддержки обоих форматов каждый инструмент формирует комбинированный ответ:

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

Если клиент не поддерживает тип application/vnd.mcp.ui+json, он считывает только поле text, предоставляя разработчику корректную разметку markdown.


Инженерные Компромиссы: Когда Использовать MCP UI (и Когда Нет)

Как и любой архитектурный паттерн, Server-Driven UI влечет за собой конкретные компромиссы:

Engineering Dimension Text-Only MCP Tools MCP UI (Server-Driven UI)
Interaction Latency Высокая (Требует полного цикла инференса LLM на каждый клик или фильтр) Мгновенная (< 50мс отправка события прямо в инструмент)
Client Surface Complexity Низкая (Обычный рендеринг markdown) Умеренная (Требует ведения реестра, валидации схем и маршрутизации действий)
Error Surface Минимальная (Ошибки текстовой генерации) Требует версионирования схем и карточек-заглушек для неизвестных компонентов
Security Surface Стандартные риски prompt injection Требует строгих декларативных схем, изоляции CSS и iframe-песочниц

Практический Вывод

Если сценарий вашего агента исключительно текстовый или выполняется в фоновом автоматическом режиме, простых текстовых инструментов более чем достаточно. Но если вы создаете приложения, в которых люди принимают решения (аппрувят pull request'ы, разбирают алерты инцидентов или фильтруют данные), MCP UI превращает громоздкую текстовую переписку в полноценный интерфейс уровня современного веб-приложения.

Share this article