MCP 让工具能被模型调用,但工具返回的 JSON 是给模型看的,不是给人看的。用户想排序、筛选、预览、确认,就需要界面。2026-07-28 规范把这件事做成了官方扩展:MCP Apps(SEP-1865)——工具通过 _meta.ui.resourceUri 指向一份 ui:// 开头的 HTML 资源,宿主把它渲染进带 CSP 的沙箱 iframe,界面再通过 postMessage 上的 JSON-RPC 回过来调用工具。这篇教程用官方 SDK 把「工具 → 界面 → 回写模型上下文」这条链路走通。
先搞懂:四个角色,一条闭环
把链路拆开只有四件事,理解它们的分工比记住 API 更重要:
| 角色 | 职责 | 实现位置 |
|---|---|---|
| 工具 | 声明「我的结果有配套界面」 | 服务端工具注册处 |
| UI 资源 | 一份 HTML,注册在 ui:// 协议下 | 服务端资源注册处 |
| 宿主 | 取资源、在沙箱 iframe 里渲染、代理界面发起的调用 | 聊天客户端 |
| 沙箱桥 | postMessage 上的 JSON-RPC,双向通信 | 视图侧 App 对象 |
与另一条路线(A2UI,走声明式 JSON 组件、不执行任何来自智能体的代码)的取舍也很清楚:MCP Apps 换来完全的样式控制权,代价是要信任沙箱与 CSP;A2UI 更安全也更可移植,但样式由客户端目录决定。给自有可控的工具做富交互面板选前者,给远程或不可信智能体渲染选后者。
Step 1:装包
# 服务端:创建 UI 资源 + 把工具与资源绑定
npm install @mcp-ui/server @modelcontextprotocol/ext-apps
# 视图侧(打包后的 HTML 里):App 对象负责与宿主通信
# 由 @modelcontextprotocol/ext-apps 提供
# 宿主侧(如果你在自建宿主):连接 MCP 服务器
npm install @modelcontextprotocol/sdk @ai-sdk/mcp
Step 2:服务端注册一份 UI 资源
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:把工具绑到资源上,并分清可见性
// 一个“只给界面用”的工具:不给模型看
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 桥的两个关键能力
界面里的 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:宿主侧——只把该给模型的东西给模型
// 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:本地验证
# 1) 起你的 MCP Server(stdio 或 HTTP 传输均可)
# 2) 选一个支持 MCP Apps 的宿主做本地验证
# - Claude Desktop
# - VS Code 里的 Copilot Chat
# 3) 触发工具调用,确认:
# - 工具返回里带 _meta.ui.resourceUri
# - 界面在沙箱中渲染,而不是当纯文本显示
# - 界面上的动作能回调到服务器并拿到数据
# 4) 再打开 iframe 的控制台,确认没有被 CSP 拦掉的资源
常见问题速查
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 界面渲染成空白 | CSP 拦掉了外部 CDN 资源 | 把依赖内联进同一份 HTML |
| 界面显示成纯文本 | 工具未声明 _meta.ui.resourceUri | 在工具注册里补上资源绑定 |
| 点按钮没反应 | 宿主未代理 iframe 发起的调用 | 检查沙箱代理路由与白名单 |
| 模型自己调了界面专属工具 | 可见性没设成 app | 用 _meta.ui.visibility 收紧 |
| 界面动作模型不知道 | 没调用 updateModelContext | 在交互后显式回写上下文 |
结语
MCP Apps 补上的是工具层的「最后一公里」:模型拿 JSON,人拿界面。它把渲染权交给服务端、把安全边界交给沙箱与 CSP、把上下文回写做成一个显式 API——三件事各归其位。对已经写过 MCP Server 的团队来说,改造量并不大,主要是两处:给工具补一个资源绑定,把界面动作回写上下文。
真正需要想清楚的是信任边界:你的工具是自有可控的,还是别人写的远程智能体?前者适合 MCP Apps,后者更适合走 A2UI 那类不执行外部代码的路线。选错路线,后面补安全成本会高得多。