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

Протокол Model Context Protocol (MCP) решил давно назревшую задачу в разработке ИИ-агентов: дал единый открытый стандарт для подключения моделей к базам данных, локальным утилитам и внешним API через JSON-RPC 2.0. Больше никаких кастомных адаптеров под каждого провайдера.
Но оставалось узкое место: стандартные инструменты MCP возвращают исключительно сырой текст или неформатированные JSON-строки.
Когда агент запрашивает корпоративную базу знаний, исследует трейсы деплоя или ищет бронирования, вываливать стену текста в чат — крайне неудобно для пользователя. Нельзя отфильтровать список в один клик, нельзя развернуть карточку с деталями, а для любого следующего шага приходится снова формулировать полный текстовый запрос, чтобы модель угадала параметры следующего вызова.
MCP UI решает эту проблему за счет объединения Server-Driven UI (SDUI) с серверами MCP. Вместо простыней текста сервер отдает типизированные схемы компонентов и действий. Фронтенд безопасно рендерит их как полноценные интерактивные React-виджеты с прямым циклом обратной связи в среду исполнения агента.
Давайте разберем архитектуру от и до: от формата передачи данных и серверов на TypeScript до безопасных React-хостов и изоляции интерфейса.
Архитектура: Трехуровневый Цикл Событий MCP UI
В стандартной интеграции MCP хост-клиент работает как пассивный канал для пересылки строк между моделью и MCP-сервером.
В MCP UI на хост ложатся две новые задачи: Реестр Компонентов (сопоставляющий строковые идентификаторы с проверенными React-компонентами) и Диспетчер Действий (передающий клики по виджетам напрямую в вызовы инструментов без повторного прогона через 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 │◄─┴──────┘
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘Жизненный цикл включает пять шагов:
- Исполнение Намерения: Агент вызывает инструмент MCP (
discover_knowledge_node) с параметрами поиска или идентификаторами сущностей. - Сериализация Двойного Результата: Сервер MCP создает два ответа: текстовую markdown-версию для терминальных клиентов и структурированную JSON-схему типа
application/vnd.mcp.ui+json. - Разрешение в Реестре: Фронтенд-хост получает результат, валидирует схему с помощью Zod и сопоставляет компонент с локальным реестром.
- Изолированное Монтирование: Хост монтирует компонент, передает типизированные свойства и подключает обработчики событий.
- Двунаправленный Обратный Вызов: При нажатии пользователем кнопки действия (например, фильтрации по тегу или предпросмотра статьи) компонент отправляет команду через клиентский слой MCP, вызывая следующий инструмент без необходимости писать текстовый промпт.
Wire Specification: The MCP UI Protocol Contract
Для сохранения полной совместимости со спецификацией Model Context Protocol данные интерфейса упаковываются в стандартные результаты выполнения инструментов через структурированные ресурсы.
The JSON-RPC 2.0 Wire Format
Когда инструмент MCP возвращает интерактивный компонент интерфейса, массив content дополняется определением ресурса:
{
"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 мы фиксируем строгий структурный контракт для всех генерируемых сервером компонентов:
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.
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) мы создаем хост-рендерер, выполняющий:
- Анализ ответов инструментов MCP на наличие ресурсов
application/vnd.mcp.ui+json. - Сопоставление имени компонента с типизированным реестром React-компонентов.
- Отправку событий взаимодействия пользователя обратно в транспортный уровень 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
Прием определений интерфейса от внешних или удаленных MCP-серверов создает потенциальные риски без эшелонированной защиты. Мы вводим три уровня безопасности:
┌──────────────────────────────────────────────────────────────────────────┐
│ 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:
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>:
<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-серверам является отказоустойчивость протокола: сервер обязан корректно работать при вызовах из консоли, систем автосборки и сред разработки без графического интерфейса.
Для поддержки обоих форматов каждый инструмент формирует комбинированный ответ:
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 превращает громоздкую текстовую переписку в полноценный интерфейс уровня современного веб-приложения.