Назад до журналу
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 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 запускає фрейм в окремому ізольованому походженні, блокуючи доступ до файлів cookie, токенів авторизації, локального сховища або DOM основного застосунку.


Headless and Terminal Fallbacks

Ключовою експлуатаційною вимогою до серверів MCP є протокольна стійкість: сервер має залишатися функціональним під час роботи з консолі, систем CI або середовищ розробки без графічного інтерфейсу.

Для підтримки обох сценаріїв кожен інструмент створює комбіновану відповідь:

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