大模型早就能把话说明白了,但它一直不太会「画界面」。 2025 年底到 2026 年初,两个协议几乎同时给出了答案------而且答案完全不同。
一个返回 HTML,一个返回 JSON。看起来前者完胜,实际上没这么简单。
一、先说清楚问题:Agent 的「最后一公里」
我们已经把 Agent 的上游打通得差不多了:
- MCP 解决了 Agent 怎么用工具、怎么读数据;
- A2A 解决了 Agent 之间怎么互相调用、怎么鉴权;
- AG-UI 解决了 Agent 后端怎么把事件流推给前端。
但还剩最后一段:Agent 想让用户「选一个日期」「填一张表」「批一个单」的时候,它到底该吐出什么?
在此之前的默认答案是 Markdown。于是我们见过太多这样的对话:
markdown
助手:好的,我为你找到了 3 家餐厅:
1. 西安名吃 ★★★★☆ 地址:...
2. 汉唐 ★★★★☆ 地址:...
请回复序号选择,并告诉我用餐时间和人数。
用户得手打「2,今晚 7 点,4 个人」。这在 2019 年可以接受,在今天不行。
MCP Apps 和 A2UI 就是对这一公里的两种回答。它们经常被放在一起比较,也确实在争夺同一块屏幕,但它们的世界观差得非常远:
MCP Apps 说:「界面是我服务端写好的,你原样渲染就行。」 A2UI 说:「界面是我这一轮现场想出来的,你用你自己的组件画出来。」
先回答标题那个问题
既然 MCP Apps 能直接返回 HTML、能渲染任意样式,为什么还需要一个只能发 JSON、还得客户端自己实现渲染器的 A2UI?
一句话版本:因为「能画什么」和「谁说了算」是两件事。
- HTML 的表现力更强,但界面长什么样由服务端决定。 你的产品里接五个厂商的 MCP Server,可能得到五种视觉语言。
- HTML 是开发者预先写好的,LLM 只能往里填数据。 界面结构没法随对话变。
- HTML 要靠 iframe 承载,出了 Web 就很尴尬。 移动端原生、可访问性、布局联动都要另想办法。
A2UI 用「表现力受限于组件目录」换回了这三样:样式主权归宿主、结构可由 LLM 现场生成、能渲染成真正的原生控件。
所以这不是谁强谁弱,而是两套不同的权衡。完整的六个分水岭在第六章,急的话可以直接跳过去。下面先把两者的机制讲清楚------不了解机制,那六条对比只是结论,记不住也用不上。
二、先建立坐标系:五个协议各在哪一层
初学者最容易犯的错,是把 MCP / A2A / AG-UI / A2UI / MCP Apps 摆在同一排比较。它们其实分属不同层次:

图 1:五个协议的分层坐标------只有 A2UI 与 MCP Apps 在同一层竞争
一句话记忆:
| 协议 | 回答的问题 | 层次 |
|---|---|---|
| MCP | Agent 怎么调工具、读数据 | 能力 |
| A2A | Agent 之间怎么互相委托 | 协作 |
| AG-UI | 消息怎么流到前端 | 传输 |
| A2UI | 这条消息渲染成什么界面 | 呈现 |
| MCP Apps | 这个工具附带什么界面 | 呈现 |
只有最后两个是真正的同层竞争关系。 前三个跟它们是互补的------A2UI 的官方传输方案就是 A2A 和 AG-UI,而 MCP Apps 本身就是 MCP 的一个扩展(SEP-1865)。
三、MCP Apps 机制详解
3.1 核心公式:MCP Apps = Tool + UI Resource
MCP Apps 是 MCP 的官方扩展(规范版本 2026-01-26),它的思路极其克制:不发明新的 UI 语言,直接用 HTML。
三个角色:

图 2:MCP Apps 的三个角色(Server / Host / View)
- Server:标准 MCP Server,额外声明 UI 资源;
- Host:聊天客户端,负责把 View 塞进 iframe,并在 Server 和 View 之间做代理;
- View:跑在沙箱 iframe 里的 UI,它本身扮演一个 MCP Client 的角色。
3.2 两段式注册
关键在于工具和 UI 资源是分开注册、靠 URI 绑定的:
ts
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
const resourceUri = "ui://get-time/mcp-app.html";
// ① 注册工具,用 _meta.ui 指向它的 UI 资源
registerAppTool(
server,
"get-time",
{
title: "Get Time",
description: "Returns the current server time.",
inputSchema: {},
_meta: { ui: { resourceUri } }, // ← 这一行是全部的魔法
},
async () => {
const time = new Date().toISOString();
return { content: [{ type: "text", text: time }] };
},
);
// ② 注册资源,返回打包好的 HTML
registerAppResource(
server,
resourceUri,
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE }, // "text/html;profile=mcp-app"
async () => {
const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] };
},
);
View 侧同样简单:
ts
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "Get Time App", version: "1.0.0" });
// 接收 Host 推下来的工具结果(要在 connect 之前挂,否则会漏掉首次结果)
app.ontoolresult = (result) => {
const time = result.content?.find((c) => c.type === "text")?.text;
document.getElementById("server-time")!.textContent = time ?? "[ERROR]";
};
// UI 里的按钮可以直接回调服务端工具
document.getElementById("get-time-btn")!.addEventListener("click", async () => {
const result = await app.callServerTool({ name: "get-time", arguments: {} });
// ...
});
app.connect();
注意这里的 ui:// 是自定义 URI scheme ,专门用来把 UI 资源和普通 MCP 资源区分开。UI 是在工具注册时就声明的,不是运行时生成的------这一点后面会反复提到,它是两个协议最根本的分水岭。
规范里给出的理由很明确:
- 可预取(Prefetching)------Host 可以在工具真正被调用之前就缓存好模板;
- 关注点分离------模板(表现)与工具结果(数据)解耦;
- 可审查(Review)------Host 可以在连接建立时就检查 UI 模板。
3.3 完整生命周期

图 3:MCP Apps 完整生命周期
有几个设计细节值得单独拎出来:
① content 与 structuredContent 分离。 工具结果里,content 是给模型看的文本,structuredContent 是给 UI 用的结构化数据。这样服务端能给 UI 喂很详细的数据,而不会撑爆模型的上下文。这是个非常实用的设计。
② 工具可见性(Tool Visibility)。 工具可以声明 visibility: ["model", "app"]。设成 ["app"] 的工具模型根本看不见,只有 View 能调------刷新按钮、翻页、表单提交这类纯 UI 交互就该这么干,免得污染 Agent 的上下文。这个设计我个人非常喜欢。
③ 渐进增强(Progressive Enhancement)。 Host 在连接时声明自己支不支持 MCP Apps,Server 据此决定要不要注册带 UI 的工具。不支持的 Host 上,工具照常工作,只是退化成纯文本。 UI 是增强,不是依赖。
3.4 安全模型:沙箱 + 声明式 CSP
因为跑的是真代码,MCP Apps 的安全全押在隔离上:
- 所有 View 跑在 sandboxed iframe 里,无法访问 Host 的 DOM、Cookie、Storage;
- 通信只走
postMessage,因此全程可审计; - Server 必须通过
_meta.ui.csp声明自己需要哪些外部域名:
ts
interface McpUiResourceCsp {
connectDomains?: string[]; // fetch / XHR / WebSocket → CSP connect-src
resourceDomains?: string[]; // 图片/脚本/样式/字体/媒体 → img-src, script-src, ...
frameDomains?: string[]; // 嵌套 iframe → frame-src
}
**「默认拒绝」**是这里的关键:不声明就一个外部连接都不许发。这直接堵死了数据外泄的路径。
3.5 主题:Host 给建议,View 自愿采纳
这是理解 MCP Apps 的关键一环。Host 会在 ui/initialize 时下发上下文(主题明暗、locale、时区、显示模式、容器尺寸、平台),并提供一组 CSS 自定义属性:
css
.container {
background: var(--color-background-primary, #ffffff);
color: var(--color-text-primary, #000000);
}
注意 var(..., fallback) 这个写法------这是软约定,不是强制。View 完全可以无视所有变量,写死自己的品牌色、字体和圆角。
对「我就是要我的品牌视觉」的服务方,这是优点 。 对「我要一个统一体验的产品」的宿主方,这是失控的开始。
记住这个点,它是后面对比的核心。
3.6 显示模式
View 可以声明自己支持哪些模式,Host 决定给不给:
| 模式 | 说明 | 适用 |
|---|---|---|
inline |
嵌在对话流里 | 图表、预览、表单 |
fullscreen |
接管整个窗口 | 编辑器、游戏、复杂看板 |
pip |
画中画悬浮 | 播放器、计时器等常驻小组件 |
规范里写得很直白:View 可以请求切换,但 Host 有最终决定权------毕竟那是 Host 自己的界面。
四、A2UI 机制详解
4.1 核心理念:像数据一样安全,像代码一样有表现力
A2UI(Agent-to-User Interface)的出发点完全相反:绝不让 LLM 生成可执行代码。
它的做法是:Agent 只发送一段声明式 JSON ,描述「我想要一个 Card,里面放一个标题和一个提交按钮」的意图;客户端从自己维护的**可信组件目录(Catalog)**里挑出对应实现来渲染。
用官方的类比来说:
| Web | A2UI |
|---|---|
| HTML 规范 | A2UI 协议 |
| Web Server | Agent |
| 浏览器引擎 | Renderer(客户端库) |
| CSS / 设计系统 | Catalog + Theme |
没有浏览器,HTML 就是一堆文本;没有 Renderer,A2UI JSON 就是死数据。
4.2 只有四条消息
v0.9 协议全部的服务端→客户端消息就这四条:
| 消息 | 作用 |
|---|---|
createSurface |
创建一个渲染面,指定用哪个 catalog |
updateComponents |
增加 / 更新组件 |
updateDataModel |
更新数据 |
deleteSurface |
销毁面 |
一个完整的最小示例(来自官方 catalog 示例库):
json
{"version":"v0.9","createSurface":{
"surfaceId":"demo",
"catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
}}
{"version":"v0.9","updateComponents":{"surfaceId":"demo","components":[
{"id":"root","component":"Column","children":["title","action_button"],
"justify":"center","align":"center"},
{"id":"title","component":"Text","text":"Click the button below","variant":"body"},
{"id":"action_button","component":"Button","child":"button_label","variant":"primary",
"action":{"event":{"name":"button_clicked","context":{}}}},
{"id":"button_label","component":"Text","text":"Click Me"}
]}}
4.3 关键设计一:扁平邻接表,不是嵌套树
看上面的 JSON------root 用 children: ["title","action_button"] 按 ID 引用子节点,而不是把子节点嵌套在自己内部。
这是专门为 LLM 优化的:
- 好流式:模型可以一个组件一个组件地吐,客户端边收边渲染,用户不用等整棵树生成完;
- 好增量 :下一轮对话想改标题?只发那一个
id: "title"的组件即可,不用重发整个界面; - 好生成:扁平结构比深度嵌套的 JSON 更不容易让模型写崩括号。
v0.9 里根节点是约定 :必须有一个 id 为 "root" 的组件。
4.4 关键设计二:结构与数据分离
组件里可以写数据绑定,而不是写死的值:
json
{"id":"party_field","component":"TextField","text":{"path":"/partySize"}}
数据走单独的消息,用 JSON Pointer 精确更新:
json
{"version":"v0.9","updateDataModel":{
"surfaceId":"demo","path":"/user/email","value":"alice@newdomain.com"
}}
只改 /user/email,/user/name 纹丝不动。绑定到该路径的组件自动重渲染。
4.5 关键设计三:Actions 分两类
这是 A2UI 相当聪明的一处设计:

图 4:A2UI 的 Action 双轨------本地执行 vs 回传 Agent
| 谁执行 | Agent 知道吗 | 典型用途 | |
|---|---|---|---|
functionCall |
本地渲染器 | ❌ | 打开链接、切换 tab、表单校验 |
event |
发回 Agent | ✅ | 提交预订、确认支付 |
event 的载荷长这样:
json
{
"id": "submit-btn", "component": "Button", "child": "btn-text",
"action": {
"event": {
"name": "submit_reservation",
"context": {
"time": {"path": "/reservationTime"},
"size": {"path": "/partySize"}
}
}
}
}
context 是数据模型的手挑子集------官方文档形容为一个「view」。好处是 Agent 不用在一棵庞大的状态树里翻找,拿到的就是这次事件需要的几个值。
渲染器解析路径后实际发出去的是:
json
{
"version": "v0.9",
"action": {
"name": "submit_reservation",
"surfaceId": "booking-surface",
"sourceComponentId": "submit-btn",
"timestamp": "2026-02-25T10:40:00Z",
"context": { "time": "7:00 PM", "size": 4 }
}
}
Agent 侧的处理通常就是把它翻译成一句「隐藏的用户输入」:
python
if action_name == "submit_reservation":
query = f"User submitted a reservation for {context['size']} people at {context['time']}."
response = await llm.generate(query)
4.6 关键设计四:本地优先的读写契约
所有输入组件(TextField / CheckBox / Slider)遵循一个明确的双向契约:
- 读(Model → View) :渲染时从绑定的
path拉值; - 写(View → Model) :用户一敲键盘,渲染器立刻同步写回本地数据模型。
这带来两个实打实的好处:
- 网络完全不感知 UI 噪音 。用户在输入框里敲的每一个字符都不出网,直到他点「提交」触发一个正式
event。你不需要写防抖,不需要担心延迟抖动。 - 没有竞态 。规范明确要求本地写入是同步的,保证「输入」一定先于「点击」提交------按钮解析
context时拿到的一定是最新值。
另外还有 checks,可以在渲染端做前置校验,不满足就自动禁用按钮:
json
{
"id": "submit-button", "component": "Button", "child": "submit-text",
"checks": [{
"condition": {"call": "required", "args": {"value": {"path": "/partySize"}}},
"message": "Party size is required"
}],
"action": {"event": {"name": "submit_booking"}}
}
⚠️ 但文档反复强调:checks 只管 UX,不管数据完整性。真正的校验必须在 Agent 侧再做一遍。
4.7 关键设计五:错误反馈闭环
这一点在 Agent 系统里特别关键。如果 Agent 生成的 JSON 违反了 catalog schema,渲染器会主动回报:
json
{
"version": "v0.9",
"error": {
"code": "VALIDATION_FAILED",
"surfaceId": "booking-surface",
"path": "/components/0/children",
"message": "Expected array of strings, got null."
}
}
Agent 接住这个错误,可以内部自我纠正后重发。这是一条给 LLM 用的编译错误信息------有它和没它,生成式 UI 的可用性差一个数量级。
4.8 Catalog:真正需要你投入的地方
每个 surface 都由一个 Catalog 驱动。Catalog 本质就是一份 JSON Schema,告诉 Agent「你能用哪些组件、哪些函数、哪些主题」。
官方提供了 Basic Catalog,18 个组件:
css
AudioPlayer Button Card CheckBox ChoicePicker Column
DateTimeInput Divider Icon Image List Modal
Row Slider Tabs Text TextField Video
但官方自己也说了,Basic Catalog 是刻意做得很稀疏的,只为了让不同渲染器都容易实现。生产环境应该定义自己的 catalog:
- 设计体系对齐:Agent 只能用你 App 里真实存在的组件和视觉语言;
- 安全与类型:catalog 就是白名单,没注册的组件根本渲染不出来;
- 别做映射层 :官方明确建议直接照你的组件库写 catalog,而不是先用 Basic Catalog 再写 adapter 转换。
协商流程是双向的:
css
客户端 --supportedCatalogIds--> 声明支持哪些目录
↓
Agent --createSurface.catalogId--> 挑一个用
↓
Agent --updateComponents--------> 按目录里的组件名生成
4.9 Renderer 现状
| 渲染器 | 平台 | v0.9.1 | v1.0 |
|---|---|---|---|
| React | Web | ✅ 稳定 | 🚧 计划中 |
| Lit(Web Components) | Web | ✅ 稳定 | 🚧 |
| Angular | Web | ✅ 稳定 | 🚧 |
| Flutter(GenUI SDK) | 移动/桌面/Web | ✅ 稳定 | 🚧 |
| SwiftUI | iOS/macOS | --- | 🚧 |
| Jetpack Compose | Android | --- | 🚧 |
三个 Web 渲染器共用底座 @a2ui/web_core------消息处理、状态管理、数据绑定都在里面,各框架只贴一层渲染层。所以协议处理逻辑在 Web 各端是完全一致的。
客户端接入大概长这样:
tsx
import { MessageProcessor } from '@a2ui/web_core/v0_9';
import { A2uiSurface, basicCatalog } from '@a2ui/react/v0_9';
const p = new MessageProcessor([basicCatalog]); // 注册 catalog
p.processMessages(agentMessages); // 喂消息
// <A2uiSurface surface={s} onAction={handleAction} />
社区渲染器也有一些:Vercel 的 json-render、A2UI-Android(Jetpack Compose)、a2ui-react-native、Lynx A2UI、AGenUI(iOS/Android/鸿蒙)。不过多数还停在 v0.8/v0.9。
五、关系:它们不是竞品
在讲区别之前,先泼一盆冷水:这两个协议在实际项目里经常同时出现。 A2UI 仓库里已经有三种成型的共存模式。

图 5:A2UI 与 MCP Apps 的三种共存形态
| 模式 | 谁装谁 | 场景 |
|---|---|---|
| A2UI over MCP | MCP 当传输,tool 返回 application/a2ui+json |
想复用 MCP 生态,但要动态 UI |
| A2UI in MCP Apps | MCP App 内部嵌一块 A2UI 渲染区 | App 主体固定,某块面板需要 LLM 动态生成 |
| MCP Apps in A2UI | A2UI 宿主用双层 iframe 承载 MCP App | 主体统一设计,个别第三方要完全自定义 |
模式二最能说明分工。 官方那个「生成式文档编辑器」demo:编辑器主体(富文本、复杂交互)是 MCP App 的原生 HTML,而「接受 / 拒绝这段改写」这类随内容动态变化的控制面板,由 A2UI 渲染。
它的消息流是这样的:
bash
MCP App 需要 UI
→ postMessage 发 JSON-RPC 给 Host(如 ui/fetch_counter_a2ui)
→ Sandbox Proxy 转发
→ Host 翻译成标准 MCP tools/call
→ Server 返回 application/a2ui+json 资源
→ 原路回传
→ MCP App 喂给自己本地的 A2UI MessageProcessor
→ 渲染
用户在 A2UI 区域点按钮,流程整个反过来走一遍。
分工原则:HTML 干「结构固定但交互复杂」的活,A2UI 干「交互简单但结构随对话变」的活。
六、区别:六个维度深挖
6.1 分水岭一:模板 vs 生成 ★最重要
这条比「HTML vs JSON」重要得多,但最容易被忽略。

图 6:最重要的分水岭------模板 vs 生成
MCP Apps 的 UI 是注册期就固定 的。这是它 prefetch、可审查、可缓存的前提,也是它的天花板:同一个工具,永远长同一个样。
A2UI 的 UI 是推理期生成的。同一个 Agent,可以根据「用户在问退款」还是「用户在选座位」给出完全不同的界面。
MCP Apps 是「带 UI 的工具」,A2UI 是「会画界面的 Agent」。
6.2 分水岭二:样式主权归谁
很多人的第一反应是「MCP Apps 返回 HTML,能渲染样式,肯定更强」。这个判断只对了一半------问题不是能不能渲染样式,而是样式归谁管。
| MCP Apps | A2UI | |
|---|---|---|
| 谁决定视觉 | Server | Host |
| 机制 | Host 提供 CSS 变量,View 自愿采纳 | Host 的 catalog 实现直接决定 |
| 强制力 | 无(View 可写死品牌色) | 完全(Agent 只能说「我要个 Button」) |
后果很现实:你的产品里接了五个不同厂商的 MCP Server,可能得到五种视觉语言、五套圆角、五种按钮手感。用户会觉得「东拼西凑」。
而 A2UI 里,即便是一个你完全不信任的远端 Agent 发来的界面,也长得和你自研页面一模一样。
6.3 分水岭三:安全模型的哲学差异

图 7:两种安全哲学
MCP Apps 的沙箱不是随便配的。A2UI 文档里给出的反例很典型:
单层 iframe 只要同时 带上
allow-scripts和allow-same-origin,里面的脚本就可以操作父 DOM、甚至把自己的sandbox属性摘掉,从而逃逸。
所以 A2UI 在承载 MCP App 时用的是 双层 iframe:
- 外层 Sandbox Proxy(同源,不加 sandbox):负责校验消息来源、维持 JSON-RPC 通道;
- 内层 通过
srcdoc注入,权限为sandbox="allow-scripts allow-forms allow-popups allow-modals",MUST NOT 带allow-same-origin、allow-top-navigation、allow-top-navigation-by-user-activation。
各自防的是:
- 去掉
allow-same-origin→ 独立源,切断 localStorage / sessionStorage / IndexedDB / Cookie; - 去掉
allow-top-navigation*→ 防window.top.location = "..."这类劫持跳转; - 额外收紧弹窗权限 + 拦截链接跳转 → 防通过新开窗口做数据外泄(这一条属于更严格的加固,会牺牲一部分正常的外链跳转能力,按业务权衡)。
这些全都不是 A2UI 自己需要操心的问题------因为它压根不执行代码。这就是两种安全哲学的成本差:一个要持续对抗浏览器沙箱的边界情况,一个只需要维护一份组件白名单。
6.4 分水岭四:iframe 的隐性代价
「返回 HTML」听着是纯赚,但在真实产品里有几笔账要算:
| 代价 | 说明 |
|---|---|
| 非 Web 端要靠 WebView | Flutter / SwiftUI / Compose 里得嵌 WebView 才能跑 iframe,性能、手势、键盘、深色模式都要单独处理 |
| 布局要协商 | iframe 高度不随内容自适应,得显式沟通尺寸------这正是 MCP Apps 要专门定义 display modes 和 container dimensions 的原因 |
| 可访问性断裂 | 屏幕阅读器、Ctrl+F 全文搜索、跨区域文本选择,都在 iframe 边界处断掉 |
| 零集成 | iframe 里的内容无法参与宿主的滚动联动、转场动画、主题过渡,视觉上永远是「贴上去的一块」 |
| 强依赖沙箱正确性 | 见上一节 |
A2UI 在移动端渲染的是真正的原生控件(Flutter Widget / 未来的 SwiftUI View),这些问题天然不存在。
6.5 分水岭五:实现成本落在谁头上(生态动力学)
这是最少被讨论、但最能解释「为什么会有两个协议」的角度。

图 8:生态动力学------实现成本落在谁头上
| 成本落在 | 收益 | |
|---|---|---|
| MCP Apps | Server 开发者(写 HTML) | 写一次,所有 Host 渲染 |
| A2UI | Host 开发者(写 catalog) | 写一次,所有 Agent 可用 |
所以选型其实可以从「你在生态里站哪个位置」倒推:
- 你是 Server 方,想让自家能力出现在别人的 Claude Desktop 里 → MCP Apps;
- 你是 Host 方,在做一个自有的 Agent 产品,要接很多 Agent → A2UI。
6.6 完整对照表
| 维度 | MCP Apps | A2UI |
|---|---|---|
| UI 载体 | HTML/JS,ui:// 资源 |
声明式组件 JSON |
| UI 结构来源 | 开发者预写,注册时声明 | LLM 运行时生成,支持流式 |
| 渲染方式 | 沙箱 iframe | 宿主原生组件 |
| 样式控制权 | Server(Host 只给 CSS 变量建议) | Host(catalog 实现说了算) |
| 表现力上限 | 高------Three.js、shader、图表库、富文本编辑器 | 受限于 catalog 词汇表 |
| 安全模型 | 运行不可信代码,靠 sandbox + CSP 关住 | 不运行代码,只接受白名单组件的数据 |
| 增量更新 | View 自管状态,Host 推 tool-result | updateDataModel + JSON Pointer 精确更新 |
| 跨端 | Web 为主(非 Web 端需 WebView) | Web / Flutter / 原生移动 / 桌面 |
| 可访问性 | iframe 内自成一体,a11y 树割裂 | 复用宿主原生控件的 a11y |
| 布局 | 需协商(container dimensions / display modes) | 组件直接参与宿主布局流 |
| 多 Agent 跨信任边界 | 多个 Server 各一个 iframe | ✅ 天然支持 |
| 错误自愈 | 无协议级机制 | VALIDATION_FAILED 回传给 Agent 自纠 |
| 降级 | ✅ 渐进增强,不支持则退回文本 | 依赖 renderer 存在 |
| 成本落点 | Server 开发者 | Host 开发者 |
| 规范归属 | MCP 官方扩展(SEP-1865) | 独立开源项目(Apache 2.0) |
七、被忽略的一章:Agent 停下来问人怎么办
前面六章讲的都是「Agent 主动画一个界面给你看」。但生成式 UI 真正的硬骨头是反过来的方向:Agent 执行到一半,需要人来批准、选择或补充信息,然后才能继续。
这就是 HITL(Human-in-the-Loop)。它比「渲染一张卡片」难得多,因为它涉及状态机:任务要暂停、要等待、要能被恢复,还要能跨越网络重试。
有意思的是,MCP 和 A2A 都为此做了专门设计,而且思路完全不同。
7.1 MCP 的答案:Elicitation
MCP 提供了 elicitation/create------服务端反过来向客户端征询用户输入。
两种模式:
| 模式 | 用途 | 数据流向 |
|---|---|---|
| Form | 结构化数据收集 | 请求带 requestedSchema,客户端据此渲染表单并校验 |
| URL | 敏感输入(凭据录入、第三方 OAuth) | 带外完成,数据不经过客户端,也不进 LLM 上下文;客户端只知道用户是否同意 |
在 2026-07-28 版规范里,Elicitation 走的是 MRTR(Multi Round-Trip Requests) 模式------注意这里的机制细节,它决定了 elicitation 能干什么、不能干什么:

图 9:MCP Elicitation 的 MRTR 流程
关键在于「重试原请求」这四个字 :服务端返回 InputRequiredResult 中止掉原来那次调用,客户端收集完输入后,带着 inputResponses 和服务端给的 requestState 重新发起同一个请求。
这个结构带来几条硬约束,实际选型时必须知道:
- 必须有一个「正在处理中」的请求可以挂靠。 Elicitation 是服务端在处理某个请求期间发出的。如果任务已经返回了句柄、在后台异步跑,此时没有请求可挂------单靠 elicitation 接不上,得配合下一节的 Tasks 扩展。
- 依赖客户端声明能力。
2026-07-28版要求客户端在每个请求 的_meta.io.modelcontextprotocol/clientCapabilities里声明elicitation(并细分form/url);客户端没声明的,服务端 MUST NOT 下发对应的elicitation/create。不是所有宿主都声明它,服务端不能假定其存在。 - Form 模式的 schema 是单层的。 规范原文:schema 限定为「flat objects with primitive properties only」,属性只能是字符串 / 数值 / 布尔 / 枚举,不支持嵌套对象和对象数组------这是为了简化客户端 UX 有意为之。多步骤向导、带子项的条件表单这类结构表达不了。
- MRTR 是无状态设计,服务端不替你记账。 规范明确:重试请求是完全独立的,服务端处理重试时不需要任何额外信息;跨轮次的上下文全靠
requestState这个不透明字符串由客户端原样带回。规范同时规定服务端 MUST 把回传的requestState当作攻击者可控输入(影响授权时 MUST 做完整性保护)、SHOULD 防重放,而「同一个requestState只能消费一次」这类不变量 MUST 由服务端自己实现。还有一条容易忽略:服务端 MUST NOT 假设客户端一定会填完输入并回来重试。 - Form 模式禁止索取敏感信息。 规范明文规定:密码、API key、access token、支付凭据不许走 form 模式,必须走 URL 模式------因为 URL 模式的数据不经过客户端和 LLM 上下文。
7.2 MCP Tasks:给长任务的中断态
上面第 1 条约束怎么破?答案是 MCP Tasks 扩展 ------它把 input_required 提升为任务状态机里的一等状态。

图 10:MCP Tasks 的任务状态机
流程变成了轮询式:
- 客户端在
_meta.io.modelcontextprotocol/clientCapabilities.extensions里声明io.modelcontextprotocol/tasks,服务端在server/discover里对等声明; - 服务端返回
CreateTaskResult(含taskId、初始状态、TTL、pollIntervalMs),任务在响应发出前就已持久化创建; - 客户端按
pollIntervalMs轮询tasks/get; - 任务转入
input_required时,tasks/get会带回一个inputRequestsmap ,客户端用tasks/update提交inputResponses,任务随即回到working; - 终态时
tasks/get返回result或error。
注意状态机里 input_required 是可以直接走向 cancelled 和 failed 的------「等不到人回答」是一等公民,不是异常路径。这个设计细节在做超时策略时很重要。
7.3 MCP Apps 给 HITL 提供的三个抓手,外加一个提案
MCP Apps 本身不定义 HITL 状态机,但它提供了三个直接相关的机制。这三个我在第三章只是一笔带过,这里展开------因为它们组合起来才是 MCP Apps 做 HITL 的真正形态。最后再说一个正在标准化的提案。
① 工具可见性 _meta.ui.visibility
规范原文对宿主的要求是 MUST 级别的:
tools/list行为 :可见性不含"model"的工具(如visibility: ["app"]),宿主 MUST NOT 放进 agent 的工具列表;tools/call行为 :不含"app"的工具,宿主 MUST 拒绝来自 App 的调用。
而规范给出的 app-only 用例里,明确列了「表单提交」:
Tools with
visibility: ["app"]are hidden from the agent but remain callable by apps viatools/call. This enables UI-only interactions (refresh buttons, form submissions) without exposing implementation details to the model.
② 三级受众划分
规范对工具结果三个字段的定位是:
| 字段 | 受众 |
|---|---|
content |
面向模型上下文与纯文本宿主的文本表示 |
structuredContent |
为 UI 渲染优化的结构化数据,不加入模型上下文 |
_meta |
附加元数据,不进入模型上下文 |
⚠️ 这里有个跨平台的坑 :字段名相同不代表受众语义相同。OpenAI Apps SDK 的文档写明 structuredContent 与 content 同时 提供给模型和组件,只有 _meta 对模型隐藏。所以「把数据放进 structuredContent 就等于对模型不可见」这个假设,换个宿主就不成立。做多宿主适配时这一条必须逐个验证,不能想当然。
③ ui/message:View 可以往对话流里写
typescript
{
jsonrpc: "2.0", id: 2,
method: "ui/message",
params: { role: "user", content: { type: "text", text: string } }
}
View 可以把一条消息写进宿主的聊天界面,宿主会把它当作用户消息,触发模型新一轮推理。这是规范提供的、把 View 里发生的事情交回给模型的合法路径。用它做「用户在卡片里操作完了,请模型回来继续」的唤醒很自然。
④ 正在标准化中:用 App 渲染 Elicitation
社区提案 ext-apps #511(2026-02-27)提出在 elicitation/create 上带 _meta.ui.resourceUri,让同时支持 MCP Apps 和 elicitation 的宿主用 App 代替平面表单 来渲染,不支持的宿主回落到 requestedSchema 平面表单。
SEP-3118 及 ext-apps 草案 #733(2026-07,评审中)把它规范化:服务端把 elicitation/create 装进 MRTR 的 InputRequiredResult 返回;宿主解析绑定的 App 资源、校验 App 给出的标准 ElicitResult、放进 inputResponses 并携带 requestState 重试原请求。
这里有一条值得注意的明文规定:App 不得绕过宿主直接重试服务端操作。 也就是说这个方案的结构是宿主中介------应答由宿主收集、宿主校验、宿主回传。资源加载、初始化或校验失败时,回落到宿主原生表单渲染。
7.4 A2A 的答案:INPUT_REQUIRED 是一等状态
A2A 走了另一条路。它把 TASK_STATE_INPUT_REQUIRED 和 TASK_STATE_AUTH_REQUIRED 直接做成任务状态机里的中断态 (v0.3.0 时期写作 input-required / auth-required):
- 服务端把任务置为该状态,在状态消息里描述所需输入;
- 流会关闭 ------规范明确:任务到达终态或中断态(
COMPLETED/FAILED/CANCELED/REJECTED/INPUT_REQUIRED)时,服务端关闭流,不再发送更新; - 客户端用携带原
taskId/contextId与新messageId的Message续答; - 任务标识跨轮次不变------这是 A2A 相比 elicitation 最大的结构优势;
- push notification 的典型触发点也包括
input-required和auth-required。
A2A 文档里给的典型场景很朴素:agent 发现信息不足或有歧义时,返回 input-required 向客户端要澄清。
但要注意 A2A 的定位 :它的对端是客户端程序(另一个 agent 或应用),续答消息由客户端构造。协议不定义渲染层,也不规定续答内容由谁产生------这跟它的名字是自洽的,Agent-to-Agent 本来就不以「有人在场」为前提。
7.5 四种机制横向对照
| 维度 | MCP Elicitation | MCP Tasks input_required |
MCP Apps 组合 | A2A INPUT_REQUIRED |
|---|---|---|---|---|
| 中断产生时机 | 服务端处理某个进行中请求期间 | 长任务异步执行中 | 同左(配合 Tasks) | 任务执行中任意时刻 |
| 请求方向 | 服务端以 InputRequiredResult 结束原请求,客户端重试 |
客户端轮询发现 | 轮询 + View 正向调用 | 状态变更 + 客户端正向续答 |
| 应答载体 | 重试原请求带 inputResponses |
tasks/update |
app-only 工具的 tools/call |
带原 taskId 的新 Message |
| 应答形状 | 单层原始属性对象 | inputResponses,按 key 对应 inputRequests |
服务端自定义 | 任意 Part,规范不约束 |
| 任务身份跨轮次 | ❌ 无 | ✅ taskId 恒定 |
✅ 随 Tasks | ✅ taskId / contextId 恒定 |
| 前置条件 | 客户端声明 capabilities.elicitation |
双方声明 tasks 扩展 | 宿主支持 MCP Apps | 客户端实现 A2A |
| 持久化 / 幂等 | 无状态,靠 requestState 回传;一次性消费由服务端自行实现 |
✅ 任务持久创建 | 随实现 | 发送消息 MAY 幂等,可用 messageId 判重 |
| 渲染 | 客户端原生表单 | 未定义 | App 自定义 UI | 未定义 |
| 敏感输入 | URL 模式带外处理 | --- | --- | AUTH_REQUIRED 独立状态 |
7.6 选型上的几条实践结论
综合下来,做 HITL 的路径选择其实比较清晰:
- 同步、短、简单表单 → Elicitation form 模式。最省事,但记得它扛不住响应丢失。
- 凭据 / OAuth 等敏感输入 → 必须 Elicitation URL 模式。这不只是建议,是规范要求。
- 长任务、要跨轮次恢复 → MCP Tasks 的
input_required,taskId恒定是刚需。 - 需要富交互界面(多问题、条件分支、带附件的审批)→ MCP Apps + app-only 提交工具。Elicitation 的单层 schema 表达不了这类结构。
- Agent 之间的委托 → A2A
INPUT_REQUIRED,它的任务身份语义最完整。
最后一条容易被忽略的规范事实:ui/message 会触发模型新一轮推理。 它的 role 固定为 "user",宿主把它当作用户消息处理,写进去的内容会直接进入模型输入。如果只是想给模型补充背景而不触发新一轮,规范另有 ui/update-model-context------它不触发 follow-up,宿主还可以推迟到下一条用户消息时再交给模型。两者别混用。
八、怎么选:决策树

图 11:选型决策树
如果落到 MCP Apps 那一支,还有个后续问题值得问一句:其中某块面板是否需要动态生成? 如果是,就走第五章的「A2UI in MCP Apps」混用模式。
再补几条实战判据:
| 情况 | 选 |
|---|---|
| 需要 ECharts / Monaco / Three.js 这类特定 npm 库 | MCP Apps(catalog 词汇表覆盖不到) |
| 接的是不可信第三方 Agent,还要跟自家界面无缝 | A2UI(这是它唯一独占的组合) |
| 只想快速给一个 tool 加个可视化 | MCP Apps(成本低得多,且自动降级) |
| 要做移动端原生体验 | A2UI(但 SwiftUI/Compose 官方渲染器还在路上) |
| Agent 需要根据用户回答动态改表单字段 | A2UI |
| 服务方有强品牌视觉诉求 | MCP Apps |
| 大量刷新/翻页交互,不想污染模型上下文 | MCP Apps (用 visibility: ["app"] 工具) |
九、落地建议与坑
A2UI 侧
- 版本选 v0.9.1 。v1.0 还是 RC,且所有官方渲染器对 v1.0 都标着 🚧 Planned。v0.8 是 legacy,消息名都不一样(
beginRendering/surfaceUpdate)。 - 导入路径带版本号 :
@a2ui/react/v0_9、provideA2Uifrom@a2ui/angular/v0_9------协议版本在编译期就分叉。 - LLM 输出必须 schema 校验 。官方示例代码自己都写了注释:「直接
json.loads很脆弱,LLM 经常加 Markdown 围栏」。务必用jsonschema.validate兜底,并接上VALIDATION_FAILED反馈闭环。 - 数据变换在 Agent 侧做完。渲染端不做日期格式化之类的计算,格式化好了再发。
- 别用 Basic Catalog 上生产。它是刻意做稀疏的。直接照你自己的设计系统写 catalog,不要写 adapter。
MCP Apps 侧
- CSP 默认拒绝 ,用到 CDN、外部 API 记得在
_meta.ui.csp里显式声明,否则线上会静默失败。 - 善用
structuredContent。给 UI 的数据别塞进content,会白白烧模型上下文。 - 纯 UI 交互用
visibility: ["app"]。刷新、分页、排序这些不该让模型看见。 ontoolresult要在app.connect()之前挂,否则会漏掉首次结果。- 主题变量带 fallback,并监听主题切换通知------Host 会在用户切换明暗模式时下发。
- 别假设显示模式。你请求 fullscreen,Host 完全可能不给。
通用
- 两者都是 2025 年底才成型的新协议,实现和规范都在快速演进,别在核心链路上做不可逆的强耦合;
- 无论哪条路线,服务端的数据完整性校验都不能省------渲染端的校验只管 UX。
十、结语
回到标题那个问题------MCP Apps 能直接返回 HTML,为什么还需要 A2UI?
现在答案应该清楚了:因为 HTML 换来的表现力,代价是样式主权交给服务端、界面结构在注册期就被冻结、以及出了 Web 就要靠 WebView 兜底。A2UI 反过来,用组件目录的边界换回了这三样。
回到最初那句对比:
MCP Apps 让 Server 说:「这是我的界面,请原样显示。」 A2UI 让 Agent 说:「我需要一个表单和一个提交按钮,剩下的你看着办。」
前者用表现力 换走了一致性和跨端能力,后者用词汇表的边界换来了统一体验、原生渲染和跨信任边界的安全。
这不是一场谁取代谁的战争。更可能的终局是:MCP Apps 成为 Agent 生态里「工具自带 UI」的事实标准,而 A2UI 成为「Agent 自己画界面」的通用语言 ------就像今天的网页里,既有 <iframe> 嵌入的第三方组件,也有服务端下发的 JSON 驱动的原生渲染。
它们最终会在同一个屏幕上共存,各干各擅长的活。
参考
- MCP 规范 :
2026-07-28版 --- Elicitation(client/elicitation)、Multi Round-Trip Requests(basic/patterns/mrtr) - MCP Tasks 扩展 :
modelcontextprotocol/ext-tasks,schema2026-07-28 - MCP Apps 规范 :
modelcontextprotocol/ext-apps--- SEP-1865,规范版本2026-01-26 - MCP Apps 文档 :
docs/overview.md、docs/quickstart.md、docs/csp-cors.md - App 渲染 Elicitation :SEP-3118(评审中)及
ext-apps相关 issue / PR - A2UI 项目 :a2ui.org ·
a2ui-project/a2ui(Apache 2.0) - A2UI 在线体验:Composer(可视化生成 JSON)、Theater(流式渲染演示)
- A2A 协议 :a2a-protocol.org
- AG-UI :ag-ui.com (CopilotKit 团队)
- Flutter GenUI SDK :docs.flutter.dev/ai/genui
本文基于 MCP 规范
2026-07-28、MCP Apps 规范2026-01-26、MCP Tasks2026-07-28、A2A v1.0 与 A2UI v0.9.1 撰写。这些协议都在快速迭代,具体细节请以官方仓库为准。