Створення 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 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 запускає фрейм в окремому ізольованому походженні, блокуючи доступ до файлів cookie, токенів авторизації, локального сховища або DOM основного застосунку.
Headless and Terminal Fallbacks
Ключовою експлуатаційною вимогою до серверів MCP є протокольна стійкість: сервер має залишатися функціональним під час роботи з консолі, систем CI або середовищ розробки без графічного інтерфейсу.
Для підтримки обох сценаріїв кожен інструмент створює комбіновану відповідь:
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 перетворює заплутане текстове листування на зручний інтерфейс повноцінного веб-застосунку.