返回日志
8 分钟阅读

构建 MCP UI:面向 AI 智能体的服务端驱动交互式组件

基于模型上下文协议(MCP)架构服务端驱动的生成式 UI,结合结构化 JSON 规范、客户端沙箱化渲染与双向动作循环。

MCPAI AgentsServer-Driven UIReactTypeScriptArchitecture
构建 MCP UI:面向 AI 智能体的服务端驱动交互式组件

模型上下文协议(Model Context Protocol,MCP)解决了 AI 智能体领域的一大痛点:它为大模型接入数据库、本地终端工具和远程 API 建立了清晰通用的 JSON-RPC 2.0 标准。我们终于不用再为每个大模型供应商编写专有的适配器了。

但这里存在一个显而易见的瓶颈:标准的 MCP 工具调用只能返回纯文本或原始 JSON 字符串。

当智能体检索内部知识库、分析部署日志或提取结构化数据时,直接在对话框里倾倒大段未排版的文本,用户体验非常糟糕。你无法直接点击过滤条目,无法展开抽屉查看详情,如果想进行后续操作,还必须手动输入一整句提示词让模型去猜测参数。

MCP UI 通过将服务端驱动 UI(SDUI)与 MCP 服务端结合解决了这个问题。服务端不再返回枯燥的文字串,而是返回类型化的组件 Schema 和动作 Payload。前端将它们安全渲染为真正的交互式 React 组件,并与智能体运行时建立双向闭环。

Loading interactive sandbox...

接下来,我们完整拆解整个架构实现——从底层传输协议设计与 TypeScript 服务端开发,到沙箱化的 React 宿主渲染器及安全隔离边界。


架构设计:三层 MCP UI 事件闭环

在传统的 MCP 接入中,前端宿主只是大模型与 MCP 服务端之间透传字符串的被动通道。

而在 MCP UI 架构中,客户端宿主承担起两项核心职责:组件注册表(Component Registry)(将组件标识映射到经过安全审计的 React 组件)和动作分发器(Action Dispatcher)(将组件交互点击直接绑定至下游工具调用,无需额外经过大模型推理)。

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 文本降级,另一组为符合 application/vnd.mcp.ui+json 的结构化 JSON 规范。
  3. 注册表解析:前端宿主接收工具执行结果,通过 Zod 校验数据签名,并在本地注册表中匹配对应组件。
  4. 沙箱化挂载:宿主挂载组件,注入强类型属性(Props)并绑定回调处理函数。
  5. 双向回调:当用户点击操作按钮(如按标签筛选或展开摘要)时,组件通过 MCP 客户端层将动作分发回服务端,直接触发下游工具调用,无需再次输入文本提示词。

Wire Specification: The MCP UI Protocol Contract

为保持与官方 Model Context Protocol 规范的完全向后兼容,UI 载荷以结构化资源形式封装在标准 MCP 工具调用结果内。

The JSON-RPC 2.0 Wire Format

当 MCP 工具生成交互式 UI 组件时,会在 content 数组中填充带有特定 MIME 类型的资源定义:

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 定义所有服务端输出 UI 组件的严格结构契约:

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

接下来使用 @modelcontextprotocol/sdk 实现一个生产就绪的 Node.js MCP 服务端。该服务端公开 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 服务端返回 UI 定义可能引入潜在安全风险。我们通过三层纵深防御体系来保障安全性:

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. 严格模式白名单(Zero Eval)

绝不允许 MCP 服务端返回未经编译的 JSX 字符串、JavaScript 函数或直接 HTML 标签。所有 UI 输出必须是经过 Zod 验证的声明式 JSON 模式。客户端宿主仅将白名单中的标识符(如 BlogDiscoveryCardMetricGrid)映射到内部经过审计的 React 组件。

2. 基于 Shadow DOM 的样式隔离

为防止第三方 MCP UI 属性注入恶意 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. 基于沙箱 Iframe 的第三方插件隔离

在支持第三方自定义复杂布局的场景下,使用沙箱 <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,内嵌组件运行于不透明源中,无法访问宿主应用的 Cookies、会话凭证、LocalStorage 或父级 DOM。


Headless and Terminal Fallbacks

协议弹性是 MCP 服务端的核心工程要求:当通过命令行工具、自动化 CI 流水线或无图形界面的 IDE 调用时,服务端必须保持完全可用。

为了兼顾两种环境,每个工具实现均应构建双重响应载荷:

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 在工程实践中也伴随着明确的得与失:

工程维度 纯文本 MCP 工具 MCP UI(服务端驱动 UI)
交互延迟 高(每次点击或过滤都需要完整的大模型推理往返) 极低(< 50ms 客户端直接分发事件至指定工具)
客户端复杂度 低(通用 Markdown 解析器) 中等(需维护组件注册表、模式校验器与动作分发器)
错误边界 极小(文本生成异常) 需设计模式版本管理与未知组件降级容错机制
安全暴露面 基础提示词注入风险 需实施 UI 沙箱化、CSS 隔离与严格参数校验

生产落地建议

如果你的智能体工作流完全是对话式的,或者在后端自主无头批量运行,纯文本工具就完全够用。但如果你正在构建人机协同的智能体软件(如人类审批 Pull Request、分流生产事故告警、交互式筛选数据集或检索技术知识库),MCP UI 能将笨拙的文本往复直接升级为应用级别的流畅交互体验。

Share this article