当模型不再只吐文本
两个协议在解决同一个难题:模型的本质输出是 token 流,但产品需要按钮、表单与图表。面对「怎么把界面送到屏幕上」,它们给出了截然相反的答案——一个发可执行的网页,一个发声明式的蓝图。读完这篇,你能彻底分清这两条路线,并按场景选对。
它们都在解决的那个难题
模型只会吐文字,产品却要按钮、表单、图表——这中间差了什么?
大语言模型的本质输出是 token 流——一段纯文本。但真实产品需要的是按钮、表单、图表、可点击的卡片,还要能把用户的点击回传给模型、把模型的进度实时反映到屏幕上。
纯文本和富交互界面之间,横着两道核心鸿沟。MCP Apps 和 A2UI,正是用两种思路去补这两道沟:
两条路线:发网页,还是发蓝图
既然都是「让模型描述一个界面」,两套协议到底差在哪?
差在送来的是什么、由谁来渲染。一条送「可执行的网页」、由沙箱执行;一条送「声明式的蓝图」、由客户端原生渲染。就这一个选择,决定了它们各自的灵活性、安全性和跨平台能力。
MCP Apps:发一段「网页」,沙箱里跑
怎么让一个工具返回的不是一句话,而是一整块能点能填的界面?
核心思路:把界面当成 MCP 的一种资源。工具不再只返回文本,而是返回一段预先声明好的 HTML,宿主在双层 iframe 沙箱里渲染它,界面再通过 JSON-RPC over postMessage 反过来调用工具。
实现原理:六步生命周期
注意「声明」和「数据」是分开的——这是它做缓存和安全审查的根基。
- 预声明 UI 资源:服务端用
ui://协议把 HTML 模板注册成静态资源,工具定义里用_meta.ui.resourceUri指向它。结构(模板)和动态数据(工具结果)就此解耦。 - 宿主预取与审查:因为模板是静态的,宿主可以在工具真正执行之前就拉取、缓存、并对 HTML 做安全审查。
- 沙箱渲染:工具执行后,宿主把 HTML 放进一个受限权限的 iframe 渲染。采用双层 iframe:外层(sandbox proxy)把内层(guest UI)和宿主页面隔离开。
- 初始化握手:内层 UI 用标准
@modelcontextprotocol/sdk与宿主建立双向通道(ui/initialize)。 - 回调工具:UI 里点了按钮要干活时,通过
postMessage发一条 JSON-RPC 2.0 消息给宿主请求调用工具——所有交互都结构化、可审计、可记录。 - 用户授权:宿主可以要求对「UI 发起的工具调用」做显式批准,敏感操作执行前留有人类监督这道闸。
get_weather(city:"上海")tools/call代码长什么样
// 1) 把 HTML 注册成 ui:// 资源(静态、可预取审查) server.registerResource("ui://charts/weather", { mimeType: "text/html+skybridge", text: "<div id=app>…</div><script>…</script>" }); // 2) 工具用 _meta 指向那段 UI server.registerTool("get_weather", { inputSchema: { city: z.string() }, _meta: { "ui.resourceUri": "ui://charts/weather" } // ← 关键 }, async ({ city }) => ({ content: [{ type: "text", text: `${city} 24°C 晴` }] // 不支持 UI 的宿主仍看到纯文本(优雅降级) }));
它的取舍
- 优点:能塞任意前端代码(图表、动画、复杂逻辑),可复用现成 Web 组件;天然活在 MCP 生态里,工具调用基础设施直接复用;不支持的宿主优雅降级为文本。
- 代价:本质是 Web,靠 iframe 沙箱兜底安全;跨原生平台(iOS/Android 原生控件)需要 WebView 承载,不是真原生。
A2UI:发一份 JSON「蓝图」,客户端原生渲染
连一行 HTML 都不传,怎么照样渲染出原生界面?
核心思路与 MCP Apps 正相反:绝不传可执行代码。Agent 只发一份纯 JSON(MIME 类型 application/json+a2ui),描述「界面由哪些组件构成、数据是什么、怎么绑定」。客户端拿自己本地的组件库把它渲染成原生控件。
实现原理:三块拼图
① 组件树是一张「扁平邻接表」,不是嵌套结构
组件不是嵌套的 JSON,而是一张扁平列表 + ID 引用来表达父子关系。为什么?LLM 生成扁平列表比生成深层嵌套更可靠(少出括号不匹配的错),且支持渐进式渲染——组件可以乱序、分批到达,先到先渲染。
② 数据与界面分离,用 JSON Pointer 绑定
组件不写死内容,而是绑定到数据模型里的路径,比如 /user/profile/name。数据模型一变,绑定的组件自动刷新——不用重发整棵组件树。这就是它高效的来源。
③ 输入组件是双向绑定
展示型组件是单向的;而输入框这类组件与数据模型建立双向绑定——用户一输入,客户端数据模型立刻更新,无需往返服务端。
代码长什么样
{
"components": [ // 扁平邻接表,靠 id 连父子
{ "id":"root", "type":"Column", "children":["title","count"] },
{ "id":"title", "type":"Text", "text":"点赞计数器" },
{ "id":"count", "type":"Text", "text":{ "$bind":"/count" } } // ← JSON Pointer 绑定
],
"dataModel": { "count": 0, "name": "" } // 数据与界面分离
}
它的取舍
- 优点:界面即数据,不执行任何 agent 生成的代码,安全性天生高;一份蓝图可同时渲染成 Web / iOS / Android / 桌面的真·原生控件;和你已有的设计系统无缝对齐。
- 代价:受限于客户端预定义的组件集合,无法任意自定义渲染;需要客户端预先实现 Widget Registry。
并排对比
所以归根结底,它俩的取舍是什么?
两个协议放在一张表里,差异一目了然——本质上是「灵活 vs 安全」「Web vs 原生」的取舍。
| 维度 | MCP Apps / MCP-UI | A2UI |
|---|---|---|
| 主导方 | Anthropic + OpenAI + 社区 | Google(Apache-2.0) |
| 传输内容 | 预构建的 HTML/CSS/JS(可执行) | 纯声明式 JSON 蓝图(不可执行) |
| 谁来渲染 | 宿主在双层 iframe 沙箱里执行 | 客户端组件库渲染成原生控件 |
| 核心机制 | ui:// 资源 + JSON-RPC over postMessage | 扁平邻接表 + JSON Pointer 数据绑定 |
| 交互回传 | postMessage 回调工具 + 用户授权门 | JSON Pointer 双向绑定,数据回写 |
| 安全模型 | iframe 沙箱隔离 + 授权拦截 | 不传可执行代码,天然无注入风险 |
| 跨平台 | Web 为主(原生需 WebView) | 真·原生跨平台 |
| 最适合 | 复杂自定义界面、复用 Web 代码 | 原生多端、高安全、标准化 UI |
那我该用哪个?
具体到我的项目,到底走哪条路线?
一句话定调:要灵活、要复用现成 Web,选 MCP Apps;要安全、要上原生多端,选 A2UI。
原始资料
本页技术细节均来自以下权威来源,建议深入阅读规范原文。