进阶 📋 6 个步骤 第 440 / 441 篇

给 MCP 工具配一块界面:用 MCP Apps 扩展把结果渲染成可交互面板

MCP Apps(SEP-1865)让工具通过 _meta.ui.resourceUri 指向一份 ui:// HTML 资源,宿主在带 CSP 的沙箱 iframe 中渲染,界面再用 postMessage 走 JSON-RPC 回调工具。本教程用官方 ext-apps 与 mcp-ui SDK 走通工具、界面、回写模型上下文的全链路。

2026.09.17· 19 分钟阅读· 约 1822 字· 🖼 Agent UI / 🔌 MCP

MCP 让工具能被模型调用,但工具返回的 JSON 是给模型看的,不是给人看的。用户想排序、筛选、预览、确认,就需要界面。2026-07-28 规范把这件事做成了官方扩展:MCP Apps(SEP-1865)——工具通过 _meta.ui.resourceUri 指向一份 ui:// 开头的 HTML 资源,宿主把它渲染进带 CSP 的沙箱 iframe,界面再通过 postMessage 上的 JSON-RPC 回过来调用工具。这篇教程用官方 SDK 把「工具 → 界面 → 回写模型上下文」这条链路走通。

🖼 本教程适合:已经写过一个能跑的 MCP Server、现在想让工具结果「看得见、点得动」的全栈与前端同学。需要 TypeScript/JavaScript 基础。服务端能力可先看「从零写一个 MCP Server」那篇。

先搞懂:四个角色,一条闭环

把链路拆开只有四件事,理解它们的分工比记住 API 更重要:

角色职责实现位置
工具声明「我的结果有配套界面」服务端工具注册处
UI 资源一份 HTML,注册在 ui:// 协议下服务端资源注册处
宿主取资源、在沙箱 iframe 里渲染、代理界面发起的调用聊天客户端
沙箱桥postMessage 上的 JSON-RPC,双向通信视图侧 App 对象

与另一条路线(A2UI,走声明式 JSON 组件、不执行任何来自智能体的代码)的取舍也很清楚:MCP Apps 换来完全的样式控制权,代价是要信任沙箱与 CSP;A2UI 更安全也更可移植,但样式由客户端目录决定。给自有可控的工具做富交互面板选前者,给远程或不可信智能体渲染选后者。

Step 1:装包

1 服务端与视图侧各需要什么
# 服务端:创建 UI 资源 + 把工具与资源绑定
npm install @mcp-ui/server @modelcontextprotocol/ext-apps

# 视图侧(打包后的 HTML 里):App 对象负责与宿主通信
# 由 @modelcontextprotocol/ext-apps 提供

# 宿主侧(如果你在自建宿主):连接 MCP 服务器
npm install @modelcontextprotocol/sdk @ai-sdk/mcp
📦 建议非平凡界面用 Vite 构建,并把产物输出成单个内联 HTML,再作为资源嵌入服务端。这样既有现代前端工程化,又保持「一个文件、一个资源」的交付形态。

Step 2:服务端注册一份 UI 资源

2 用 ui:// 协议登记 HTML
import { createUIResource } from '@mcp-ui/server';
import { registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server';

// 创建一个 UI 资源:内容就是一段 HTML 字符串
const widget = await createUIResource({
  uri: 'ui://my-server/widget',
  content: { type: 'rawHtml', htmlString: '<div id="app">加载中…</div>' },
  encoding: 'text',
});

// 注册资源:mimeType 标记 text/html,表明这是一个 MCP App
registerAppResource(server, 'widget_ui', widget.resource.uri, {}, async () => ({
  contents: [widget.resource],
}));

一个常见误会:UI 资源不是普通文件下载。宿主会主动读取、并在沙箱 iframe 中以 deny-by-default 的 CSP 渲染,因此外部 CDN 脚本默认会被拦。需要第三方库时,把依赖打进同一份 HTML 里。

Step 3:把工具绑到资源上,并分清可见性

3 两个可见性,两拨工具
// 一个“只给界面用”的工具:不给模型看
registerAppTool(server, 'render_chart', {
  title: '渲染图表',
  _meta: { ui: { resourceUri: widget.resource.uri } },   // 绑定界面
}, async (args) => ({ content: [{ type: 'text', text: 'ok' }] }));

// 可见性由 _meta.ui.visibility 声明:
//   "model" -> 可以传给模型
//   "app"   -> 只能留在宿主侧,供 iframe 请求,绝不暴露给模型

最容易被忽略的一条规则:只有真正能安全获取并渲染 MCP App 资源的宿主,才应该声明这项能力。也就是说,宿主侧「读取资源 → 代理调用 → 沙箱渲染」这三件事是必做项,不是可选增强。只声明不实现,等于向服务端撒谎。

Step 4:视图侧——App 桥的两个关键能力

4 回调工具 + 回写上下文

界面里的 HTML 通过 App 对象与宿主对话。两个方法与纯静态 HTML 拉开差距:

// 1) 在界面上点一下就回服务器调工具拿数据
const data = await app.callServerTool({
  name: 'get_metrics',
  arguments: { range: 'last_7_days' },
});
renderChart(data);

// 2) 把界面上的动作写回模型上下文,
//    让 LLM 在后续对话里“知道”用户刚在界面上做了什么
await app.updateModelContext({
  type: 'user_action',
  detail: '将时间范围切换为最近 7 天',
});
🔑 updateModelContext 是 MCP Apps 相对「工具返回一段静态 HTML」的本质差别。没有它,界面只是个渲染器;有了它,界面成为智能体感知用户意图的一条输入通道。

Step 5:宿主侧——只把该给模型的东西给模型

5 三件事一件都不能省
// 1) 连接时声明宿主能力(只有确实能渲染时才声明)
new McpClient({
  transport,
  mcpAppClientCapabilities: {
    extensions: {
      'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] },
    },
  },
});

// 2) 聊天路由只把 visibility=model 的工具交给 streamText / generateText
//    带 app 可见性的工具留在宿主侧

// 3) 工具结果带 MCP App 元数据时:
//    读取 ui:// 资源 -> 沙箱 iframe 渲染 -> 把 iframe 发起的允许请求代理回服务器

两条边界:①沙箱 iframe 里发起的 tools/call 必须走白名单代理,不能给它一个能调任意工具的直通口;②iframe 需要跨源时记得配 allow,但不要为了省事关掉 CSP——沙箱与 CSP 正是这类界面对抗提示词注入的主要防线。

Step 6:本地验证

6 从最简单处开始
# 1) 起你的 MCP Server(stdio 或 HTTP 传输均可)
# 2) 选一个支持 MCP Apps 的宿主做本地验证
#    - Claude Desktop
#    - VS Code 里的 Copilot Chat
# 3) 触发工具调用,确认:
#    - 工具返回里带 _meta.ui.resourceUri
#    - 界面在沙箱中渲染,而不是当纯文本显示
#    - 界面上的动作能回调到服务器并拿到数据
# 4) 再打开 iframe 的控制台,确认没有被 CSP 拦掉的资源
🧪 排错顺序建议:先确认资源能读到(resources/read 有返回)、再确认能渲染、最后才调桥的通信。顺序颠倒会在「到底是 CSP 拦了还是桥断了」上浪费很多时间。

常见问题速查

现象常见原因处理
界面渲染成空白CSP 拦掉了外部 CDN 资源把依赖内联进同一份 HTML
界面显示成纯文本工具未声明 _meta.ui.resourceUri在工具注册里补上资源绑定
点按钮没反应宿主未代理 iframe 发起的调用检查沙箱代理路由与白名单
模型自己调了界面专属工具可见性没设成 app用 _meta.ui.visibility 收紧
界面动作模型不知道没调用 updateModelContext在交互后显式回写上下文

结语

MCP Apps 补上的是工具层的「最后一公里」:模型拿 JSON,人拿界面。它把渲染权交给服务端、把安全边界交给沙箱与 CSP、把上下文回写做成一个显式 API——三件事各归其位。对已经写过 MCP Server 的团队来说,改造量并不大,主要是两处:给工具补一个资源绑定,把界面动作回写上下文。

真正需要想清楚的是信任边界:你的工具是自有可控的,还是别人写的远程智能体?前者适合 MCP Apps,后者更适合走 A2UI 那类不执行外部代码的路线。选错路线,后面补安全成本会高得多。

← 返回教程中心