告别AI界面翻车!深度拆解MCP Apps与A2UI两套生成式UI协议

文章目录

    • 前言
    • [1、先讲清楚问题:Agent 的「最后一公里」](#1、先讲清楚问题:Agent 的「最后一公里」)
    • 2、先建立坐标系:五个协议各在哪一层
    • [3、MCP Apps 机制详解](#3、MCP Apps 机制详解)
      • [3.1 核心公式:MCP Apps = Tool + UI Resource](#3.1 核心公式:MCP Apps = Tool + UI Resource)
      • [3.2 两段式注册](#3.2 两段式注册)
      • [3.3 完整生命周期](#3.3 完整生命周期)
      • [3.4 安全模型:沙箱 + 声明式 CSP](#3.4 安全模型:沙箱 + 声明式 CSP)
      • [3.5 主题:Host 给建议,View 自愿采纳](#3.5 主题:Host 给建议,View 自愿采纳)
      • [3.6 显示模式](#3.6 显示模式)
    • [4、A2UI 机制详解](#4、A2UI 机制详解)
      • [4.1 核心理念:像数据一样安全,像代码一样有表现力](#4.1 核心理念:像数据一样安全,像代码一样有表现力)
      • [4.2 只有四条消息](#4.2 只有四条消息)
      • [4.3 关键设计一:扁平邻接表,不是嵌套树](#4.3 关键设计一:扁平邻接表,不是嵌套树)
      • [4.4 关键设计二:结构与数据分离](#4.4 关键设计二:结构与数据分离)
      • [4.5 关键设计三:Actions 分两类](#4.5 关键设计三:Actions 分两类)
      • [4.6 关键设计四:本地优先的读写契约](#4.6 关键设计四:本地优先的读写契约)
      • [4.7 关键设计五:错误反馈闭环](#4.7 关键设计五:错误反馈闭环)
      • [4.8 Catalog:真正需要你投入的地方](#4.8 Catalog:真正需要你投入的地方)
      • [4.9 Renderer 现状](#4.9 Renderer 现状)
    • 5、关系:它们不是竞品
    • 6、区别:六个维度深挖
      • [6.1 分水岭一:模板 vs 生成 ★最重要](#6.1 分水岭一:模板 vs 生成 ★最重要)
      • [6.2 分水岭二:样式主权归谁](#6.2 分水岭二:样式主权归谁)
      • [6.3 分水岭三:安全模型的哲学差异](#6.3 分水岭三:安全模型的哲学差异)
      • [6.4 分水岭四:iframe 的隐性代价](#6.4 分水岭四:iframe 的隐性代价)
      • [6.5 分水岭五:实现成本落在谁头上(生态动力学)](#6.5 分水岭五:实现成本落在谁头上(生态动力学))
      • [6.6 完整对照表](#6.6 完整对照表)
    • [7、被忽略的一章:Agent 停下来问人怎么办](#7、被忽略的一章:Agent 停下来问人怎么办)
      • [7.1 MCP 的答案:Elicitation](#7.1 MCP 的答案:Elicitation)
      • [7.2 MCP Tasks:给长任务的中断态](#7.2 MCP Tasks:给长任务的中断态)
      • [7.3 MCP Apps 给 HITL 提供的三个抓手,外加一个提案](#7.3 MCP Apps 给 HITL 提供的三个抓手,外加一个提案)
      • [7.4 A2A 的答案:`INPUT_REQUIRED` 是一等状态](#7.4 A2A 的答案:INPUT_REQUIRED 是一等状态)
      • [7.5 四种机制横向对照](#7.5 四种机制横向对照)
      • [7.6 选型上的几条实践结论](#7.6 选型上的几条实践结论)
    • 8、怎么选:决策树

P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365

前言

大模型这玩意儿,聊天的时候上知天文下知地理,你让它说人话,它说得比人还像人。可你一旦让它画个界面出来?当场社死。

这是我真实的体感:跟 AI 对话,字字珠玑;让 AI 出界面,满地鸡毛。2025 年底到 2026 年初,两个协议几乎同时跳出来说要解决这个事,而且路子完全不一样------一个直接甩 HTML,一个只给你 JSON。

光看表面,前者像开挂,后者像自虐。但你要是真信了这个表面,后面踩坑的姿势会非常优美。

1、先讲清楚问题:Agent 的「最后一公里」

Agent 的上游我们基本都打通了:

  • MCP 管的是 Agent 怎么调工具、怎么读数据;
  • A2A 管的是 Agent 之间怎么互相调用、怎么鉴权;
  • AG-UI 管的是 Agent 后端怎么把事件流推给前端。

看起来功德圆满了对吧?还差最后一哆嗦:Agent 想让用户「选个日期」「填张表」「批个单」的时候,它到底该吐出个啥?

在此之前,默认答案是 Markdown。于是你就能见到这种神仙对话:

markdown 复制代码
助手:好的,我为你找到了 3 家餐厅:
1. 西安名吃 ★★★★☆ 地址:...
2. 汉唐 ★★★★☆ 地址:...
请回复序号选择,并告诉我用餐时间和人数。

然后你得像给领导汇报工作一样,亲手打出「2,今晚 7 点,4 个人」。

这体验放 2019 年,行,大家都没见过世面。放今天?你试试让对象用这玩意儿订个位,手机能不能保住都不好说。

MCP AppsA2UI 就是对这一公里的两种回答。俩兄弟经常被拉出来 PK,也确实在抢同一块屏幕,但世界观差得不是一星半点:

MCP Apps 的意思:界面是我服务端写好的,你原样渲染就行。

A2UI 的意思:界面是我这轮现场想出来的,你用你自己的组件画出来。

翻译一下:一个像甲方直接甩了张设计图过来,另一个像乙方说「我描述下需求,你看着办」。

先回答标题那个问题:既然 MCP Apps 能直接返回 HTML、能渲染任意样式,为什么还需要一个只能发 JSON、还得客户端自己实现渲染器的 A2UI?

一句话版本:因为「能画什么」和「谁说了算」是两件事。

  • **HTML 表现力确实更强,但界面长什么样由服务端决定。**你产品里接五个厂商的 MCP Server,可能得到五种视觉语言------那不是设计,那是精神分裂现场。
  • **HTML 是开发者预先写好的,LLM 只能往里填数据。**界面结构没法随对话变。就像别人给你做了个 PPT 模板,你只能改字,改不了版式。
  • **HTML 要靠 iframe 承载,出了 Web 就很尴尬。**移动端原生、可访问性、布局联动,都得另想办法。

A2UI 用「表现力受限于组件目录」换回了这三样:样式主权归宿主、结构可由 LLM 现场生成、能渲染成真正的原生控件。

所以这不是谁强谁弱,是两套不同的权衡。完整的六个分水岭在第六章,急的话可以直接跳过去。不急的话往下看------不了解机制,那六条对比只是结论,记不住也用不上。

2、先建立坐标系:五个协议各在哪一层

初学者最容易犯的错,是把 MCP / A2A / AG-UI / A2UI / MCP Apps 摆在同一排比较。它们其实不在一个维度上,就像你把食堂的大厨、传菜员、收银员和门口那个只会拍照发朋友圈的经理放一起比谁更会颠勺------格局一开始就错了。

协议 回答的问题 层次
MCP Agent 怎么调工具、读数据 能力
A2A Agent 之间怎么互相委托 协作
AG-UI 消息怎么流到前端 传输
A2UI 这条消息渲染成什么界面 呈现
MCP Apps 这个工具附带什么界面 呈现

只有最后两个是真正的同层竞争关系。前三个跟它们是互补的------A2UI 的官方传输方案就是 A2A 和 AG-UI,而 MCP Apps 本身就是 MCP 的一个扩展(SEP-1865)。

一句话记忆:MCP 管手,A2A 管嘴,AG-UI 管嗓子,A2UI 和 MCP Apps 管脸。脸和脸打架,关手什么事?

3、MCP Apps 机制详解

3.1 核心公式:MCP Apps = Tool + UI Resource

MCP Apps 是 MCP 的官方扩展(规范版本 2026-01-26),思路极其克制:不发明新的 UI 语言,直接用 HTML。

三个角色:

  • Server:标准 MCP Server,额外声明 UI 资源;
  • Host:聊天客户端,负责把 View 塞进 iframe,并在 Server 和 View 之间做代理;
  • View:跑在沙箱 iframe 里的 UI,它本身扮演一个 MCP Client 的角色。

说白了:Server 是后厨,Host 是服务员,View 是端上桌的那道菜。菜长啥样,后厨说了算。

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 是在工具注册时就声明的,不是运行时生成的------这一点后面会反复提到,它是两个协议最根本的分水岭。

规范里给出的理由很明确:

  1. 可预取(Prefetching)------Host 可以在工具真正被调用之前就缓存好模板;
  2. 关注点分离------模板(表现)与工具结果(数据)解耦;
  3. 可审查(Review)------Host 可以在连接建立时就检查 UI 模板。

说人话:先把菜谱定好,客人点菜的时候直接开炒。而 A2UI 是客人点菜的时候,厨师现场发明新菜。

3.3 完整生命周期

整个流程分五步:发现 → 初始化 → 数据下发 → 交互 → 卸载 。发现阶段 Host 拉 tools/list(带 UI 元数据);初始化时渲染 iframe、载入 UI 资源,走 ui/initialize 下发主题、能力、容器尺寸,然后 ui/notifications/initialized 确认;数据阶段推 tool-input / tool-result;交互阶段用户操作 → tools/call → result 回传,循环往复;卸载时走 ui/resource-teardown

有几个设计细节值得单独拎出来:

**① contentstructuredContent 分离。**工具结果里,content 是给模型看的文本,structuredContent 是给 UI 用的结构化数据。这样服务端能给 UI 喂很详细的数据,而不会撑爆模型的上下文。这是个非常实用的设计------给模型看的和给界面看的分开,别让模型把界面数据也背下来,人家的上下文很贵的。

② 工具可见性(Tool Visibility)。工具可以声明 visibility: ["model", "app"]。设成 ["app"] 的工具模型根本看不见,只有 View 能调------刷新按钮、翻页、表单提交这类纯 UI 交互就该这么干,免得污染 Agent 的上下文。这个设计我个人非常喜欢:有些按钮存在的意义就是给用户按着玩的,何必让 AI 知道呢。

**③ 渐进增强(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 完全可以无视所有变量,写死自己的品牌色、字体和圆角。

对「我就是要我的品牌视觉」的服务方,这是优点 。对「我要一个统一体验的产品」的宿主方,这是失控的开始

记住这个点,它是后面对比的核心。翻译:Host 说「建议你用深色主题」,View 说「谢谢,我就要我的大红色,你管得着吗」。

3.6 显示模式

View 可以声明自己支持哪些模式,Host 决定给不给:

模式 说明 适用
inline 嵌在对话流里 图表、预览、表单
fullscreen 接管整个窗口 编辑器、游戏、复杂看板
pip 画中画悬浮 播放器、计时器等常驻小组件

规范里写得很直白:View 可以请求切换,但 Host 有最终决定权------毕竟那是 Host 自己的界面。翻译:你可以申请,但我可以不同意。职场 PUA 也不过如此。

4、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 就是死数据。

翻译:AI 负责点菜,但菜怎么做,由你家的厨房说了算。AI 甚至不知道你家厨房长啥样。

4.2 只有四条消息

v0.9 协议全部的服务端→客户端消息就这四条:

消息 作用
createSurface 创建一个渲染面,指定用哪个 catalog
updateComponents 增加 / 更新组件
updateDataModel 更新数据
deleteSurface 销毁面

四条消息,多一条都没有。你让那帮天天造 REST 接口的人来看,能感动到哭------终于有协议比他们还能克制。

一个完整的最小示例(来自官方 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------rootchildren: ["title","action_button"] 按 ID 引用子节点,而不是把子节点嵌套在自己内部。

这是专门为 LLM 优化的:

  • 好流式:模型可以一个组件一个组件地吐,客户端边收边渲染,用户不用等整棵树生成完;
  • 好增量 :下一轮对话想改标题?只发那一个 id: "title" 的组件即可,不用重发整个界面;
  • 好生成:扁平结构比深度嵌套的 JSON 更不容易让模型写崩括号。

v0.9 里根节点是约定 :必须有一个 id"root" 的组件。

翻译:扁平结构就是让 LLM 少写点括号。你要知道,括号是 LLM 的宿敌------让它写三层嵌套 JSON,它能给你写出一个让人血压飙升的东西。

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 相当聪明的一处设计:

谁执行 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)

翻译:本地能干的活(切 tab、校验表单)绝不麻烦 AI,只有真正需要拍板的(提交订单、确认预约)才往上报。这觉悟,比某些职场甩锅侠高到不知道哪里去了。

4.6 关键设计四:本地优先的读写契约

所有输入组件(TextField / CheckBox / Slider)遵循一个明确的双向契约:

  • 读(Model → View) :渲染时从绑定的 path 拉值;
  • 写(View → Model) :用户一敲键盘,渲染器立刻同步写回本地数据模型。

这带来两个实打实的好处:

  1. 网络完全不感知 UI 噪音 。用户在输入框里敲的每一个字符都不出网,直到他点「提交」触发一个正式 event你不需要写防抖,不需要担心延迟抖动。
  2. 没有竞态 。规范明确要求本地写入是同步的,保证「输入」一定先于「点击」提交------按钮解析 context 时拿到的一定是最新值。

翻译:你打字的时候,每个字符都在本地老实待着,只有提交那一刻才出门见人。这比某些一敲键盘就全网广播的 App 体面多了。

另外还有 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 侧再做一遍。翻译:前端校验是礼貌,后端校验是底线。这个道理,前端同学应该深有体会------你以为你拦住了,人家 curl 一下全给你捅穿。

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 的可用性差一个数量级。

翻译:相当于 LLM 写代码有编译器在旁边盯着,报错了还能自己改。这待遇,比我们当年用 VB 强多了。

4.8 Catalog:真正需要你投入的地方

每个 surface 都由一个 Catalog 驱动。Catalog 本质就是一份 JSON Schema,告诉 Agent「你能用哪些组件、哪些函数、哪些主题」。

官方提供了 Basic Catalog ,18 个组件:

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 转换。

协商流程是双向的:

复制代码
客户端 --supportedCatalogIds--> 声明支持哪些目录
  ↓ Agent --createSurface.catalogId--> 挑一个用
  ↓ Agent --updateComponents--------> 按目录里的组件名生成

翻译:Catalog 就是你给 AI 画的圈。圈外的东西,AI 想画也画不出来。这叫什么?这叫把创造力关进笼子------但笼子是你自己设计的,还挺好看。

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-renderA2UI-Android(Jetpack Compose)、a2ui-react-nativeLynx A2UIAGenUI(iOS/Android/鸿蒙)。不过多数还停在 v0.8/v0.9。

翻译:v1.0 那一列全是 🚧,看着就像工地。兄弟们再等等,等他们把路修好再上车。

5、关系:它们不是竞品

在讲区别之前,先泼一盆冷水:**这两个协议在实际项目里经常同时出现。**A2UI 仓库里已经有三种成型的共存模式。

模式 谁装谁 场景
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 渲染。

它的消息流是这样的:

复制代码
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、区别:六个维度深挖

6.1 分水岭一:模板 vs 生成 ★最重要

这条比「HTML vs JSON」重要得多,但最容易被忽略。

MCP Apps 的 UI 是注册期就固定 的。这是它 prefetch、可审查、可缓存的前提,也是它的天花板:同一个工具,永远长同一个样。

A2UI 的 UI 是推理期生成的。同一个 Agent,可以根据「用户在问退款」还是「用户在选座位」给出完全不同的界面。

MCP Apps 是「带 UI 的工具」,A2UI 是「会画界面的 Agent」。

翻译:MCP Apps 是固定套餐,A2UI 是今日特调。前者稳定,后者惊喜------当然,也可能惊吓。

6.2 分水岭二:样式主权归谁

很多人的第一反应是「MCP Apps 返回 HTML,能渲染样式,肯定更强」。这个判断只对了一半------问题不是能不能渲染样式,而是样式归谁管。

MCP Apps A2UI
谁决定视觉 Server Host
机制 Host 提供 CSS 变量,View 自愿采纳 Host 的 catalog 实现直接决定
强制力 无(View 可写死品牌色) 完全(Agent 只能说「我要个 Button」)

后果很现实:你的产品里接了五个不同厂商的 MCP Server,可能得到五种视觉语言、五套圆角、五种按钮手感。用户会觉得「东拼西凑」。

而 A2UI 里,即便是一个你完全不信任的远端 Agent 发来的界面,也长得和你自研页面一模一样

翻译:接五个 MCP Server 的界面,像五个设计师各做了一版然后拼在一起,连设计师本人都认不出自己的作品。而 A2UI 是:不管谁来,进了我家就得穿我家校服。

6.3 分水岭三:安全模型的哲学差异

MCP Apps 的思路是把不可信代码关进笼子:执行任意 JS,靠 sandbox iframe + 声明式 CSP 白名单,安全性 = 沙箱实现的正确性。

A2UI 的思路是根本没有代码要关:只有声明式 JSON,catalog 白名单校验组件,不认识的组件直接丢弃,安全性 = 词汇表的边界。

MCP Apps 的沙箱不是随便配的。A2UI 文档里给出的反例很典型:单层 iframe 只要同时 带上 allow-scriptsallow-same-origin,里面的脚本就可以操作父 DOM、甚至把自己的 sandbox 属性摘掉,从而逃逸。

所以 A2UI 在承载 MCP App 时用的是双层 iframe

  • 外层 Sandbox Proxy(同源,不加 sandbox):负责校验消息来源、维持 JSON-RPC 通道;
  • 内层 通过 srcdoc 注入,权限为 sandbox="allow-scripts allow-forms allow-popups allow-modals"MUST NOTallow-same-originallow-top-navigationallow-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),这些问题天然不存在。

翻译:iframe 就像合租房里的隔断间------看着挺独立,实际隔音为零,隔壁放个屁你都能听见,更别提什么「我的地盘我做主」了。A2UI 是直接给你一间正经的独立房,还带独立卫浴。

6.5 分水岭五:实现成本落在谁头上(生态动力学)

这是最少被讨论、但最能解释「为什么会有两个协议」的角度。

MCP Apps 的赌注:Host 少(Claude Desktop 等),Server 多(成千上万),让 Server 写一次 HTML,所有 Host 通吃。目标:Server 生态繁荣。

A2UI 的赌注:我自己是 Host,要接入很多来路不明的 Agent。我实现一次 catalog,所有 Agent 通吃。目标:UI 一致性 + 跨端复用。

成本落在 收益
MCP Apps Server 开发者(写 HTML) 写一次,所有 Host 渲染
A2UI Host 开发者(写 catalog) 写一次,所有 Agent 可用

所以选型其实可以从「你在生态里站哪个位置」倒推:

  • 你是 Server 方,想让自家能力出现在别人的 Claude Desktop 里 → MCP Apps;
  • 你是 Host 方,在做一个自有的 Agent 产品,要接很多 Agent → A2UI。

翻译:MCP Apps 是「一次开发,到处渲染」,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)

一个用表现力换走了一致性和跨端能力,一个用词汇表的边界换来了统一体验、原生渲染和跨信任边界的安全。各有各的取舍,别问谁赢,先问你是哪边的。

7、被忽略的一章:Agent 停下来问人怎么办

前面六章讲的都是「Agent 主动画一个界面给你看」。但生成式 UI 真正的硬骨头是反过来的方向:Agent 执行到一半,需要人来批准、选择或补充信息,然后才能继续。

这就是 HITL(Human-in-the-Loop)。它比「渲染一张卡片」难得多,因为它涉及状态机:任务要暂停、要等待、要能被恢复,还要能跨越网络重试。

有意思的是,MCP 和 A2A 都为此做了专门设计,而且思路完全不同。

翻译:前面都是「AI 给你表演」,这一章是「AI 演到一半突然卡住,回头问你:大哥,下一步咋整?」------这才是日常。

7.1 MCP 的答案:Elicitation

MCP 提供了 elicitation/create------服务端反过来向客户端征询用户输入

两种模式:

模式 用途 数据流向
Form 结构化数据收集 请求带 requestedSchema,客户端据此渲染表单并校验
URL 敏感输入(凭据录入、第三方 OAuth) 带外完成,数据不经过客户端,也不进 LLM 上下文;客户端只知道用户是否同意

2026-07-28 版规范里,Elicitation 走的是 MRTR(Multi Round-Trip Requests) 模式------注意这里的机制细节,它决定了 elicitation 能干什么、不能干什么:

关键在于「重试原请求」这四个字 :服务端返回 InputRequiredResult 中止掉原来那次调用,客户端收集完输入后,带着 inputResponses 和服务端给的 requestState 重新发起同一个请求

这个结构带来几条硬约束,实际选型时必须知道:

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

翻译:Elicitation 就是「你办事办到一半发现缺材料,回头找用户要,要完从头再办」。注意,是「从头再办」------这跟某些行政窗口的体验惊人的一致。

7.2 MCP Tasks:给长任务的中断态

上面第 1 条约束怎么破?答案是 MCP Tasks 扩展 ------它把 input_required 提升为任务状态机里的一等状态。

流程变成了轮询式:

  1. 客户端在 _meta.io.modelcontextprotocol/clientCapabilities.extensions 里声明 io.modelcontextprotocol/tasks,服务端在 server/discover 里对等声明;
  2. 服务端返回 CreateTaskResult(含 taskId、初始状态、TTL、pollIntervalMs),任务在响应发出前就已持久化创建
  3. 客户端按 pollIntervalMs 轮询 tasks/get
  4. 任务转入 input_required 时,tasks/get 会带回一个 inputRequests map ,客户端用 tasks/update 提交 inputResponses,任务随即回到 working
  5. 终态时 tasks/get 返回 resulterror

注意状态机里 input_required 是可以直接走向 cancelledfailed 的------「等不到人回答」是一等公民,不是异常路径。这个设计细节在做超时策略时很重要。

翻译:任务状态机里专门给「等人」留了个状态,等不到人就标记失败。这叫什么?这叫把「用户鸽了」当作一种正常业务结果来设计。人间真实。

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 via tools/call. This enables UI-only interactions (refresh buttons, form submissions) without exposing implementation details to the model.

② 三级受众划分

规范对工具结果三个字段的定位是:

字段 受众
content 面向模型上下文与纯文本宿主的文本表示
structuredContent 为 UI 渲染优化的结构化数据,不加入模型上下文
_meta 附加元数据,不进入模型上下文

⚠️ 这里有个跨平台的坑:字段名相同不代表受众语义相同。OpenAI Apps SDK 的文档写明 structuredContentcontent 同时 提供给模型和组件,只有 _meta 对模型隐藏。所以「把数据放进 structuredContent 就等于对模型不可见」这个假设,换个宿主就不成立。做多宿主适配时这一条必须逐个验证,不能想当然。

翻译:同一件衣服,在不同牌子的店里挂的位置不一样。你以为塞进 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 不得绕过宿主直接重试服务端操作。也就是说这个方案的结构是宿主中介------应答由宿主收集、宿主校验、宿主回传。资源加载、初始化或校验失败时,回落到宿主原生表单渲染。

翻译:中介模式,懂的都懂。App 想自己偷偷把事情办了?不行,必须过宿主这一手。像极了某些流程里必须盖的那枚章。

7.4 A2A 的答案:INPUT_REQUIRED 是一等状态

A2A 走了另一条路。它把 TASK_STATE_INPUT_REQUIREDTASK_STATE_AUTH_REQUIRED 直接做成任务状态机里的中断态 (v0.3.0 时期写作 input-required / auth-required):

  • 服务端把任务置为该状态,在状态消息里描述所需输入;
  • 流会关闭 ------规范明确:任务到达终态或中断态(COMPLETED / FAILED / CANCELED / REJECTED / INPUT_REQUIRED)时,服务端关闭流,不再发送更新;
  • 客户端用携带taskId / contextId 与新 messageIdMessage 续答;
  • 任务标识跨轮次不变------这是 A2A 相比 elicitation 最大的结构优势;
  • push notification 的典型触发点也包括 input-requiredauth-required

A2A 文档里给的典型场景很朴素:agent 发现信息不足或有歧义时,返回 input-required 向客户端要澄清。

但要注意 A2A 的定位 :它的对端是客户端程序(另一个 agent 或应用),续答消息由客户端构造。协议不定义渲染层,也不规定续答内容由谁产生------这跟它的名字是自洽的,Agent-to-Agent 本来就不以「有人在场」为前提。

翻译:A2A 的世界里没有人类什么事,两个 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_requiredtaskId 恒定是刚需。
  • 需要富交互界面(多问题、条件分支、带附件的审批)→ MCP Apps + app-only 提交工具。Elicitation 的单层 schema 表达不了这类结构。
  • Agent 之间的委托 → A2A INPUT_REQUIRED,它的任务身份语义最完整。

最后一条容易被忽略的规范事实:**ui/message 会触发模型新一轮推理。**它的 role 固定为 "user",宿主把它当作用户消息处理,写进去的内容会直接进入模型输入。如果只是想给模型补充背景而不触发新一轮,规范另有 ui/update-model-context------它不触发 follow-up,宿主还可以推迟到下一条用户消息时再交给模型。两者别混用。

翻译:ui/message 相当于「按铃喊 AI 起床干活」,ui/update-model-context 相当于「往 AI 枕头底下塞纸条」。一个会醒,一个不会。别用错,不然你半夜会被 AI 的「收到」吓醒。

8、怎么选:决策树

核心问题就一个:UI 结构是 LLM 每轮重新决定的吗?

  • :结构固定,只有数据在变 → 选 MCP Apps(图表/地图/播放器/3D/编辑器/看板)。
  • :按对话上下文现场组装 → 再问一句:需要 Web 之外渲染吗?或必须融进设计体系吗?
    • → 选 A2UI
    • 否,且 Server 要掌控外观 → 选 MCP Apps

如果落到 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"] 工具)

翻译:决策树这东西,看着高大上,实际就是「如果你想要 A 就选 A,想要 B 就选 B」。但没办法,这类问题确实只能这么答。

P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者,这个教程里内容讲解通俗易懂且风趣幽默,对我帮助很大。我想与大家分享这个宝藏教程,请点击下方链接查看,传送门https://blog.csdn.net/qq_74013365

相关推荐
志栋智能6 小时前
超自动化运维与DevOps的深度融合
运维·网络·人工智能·安全·自动化
robottt6666 小时前
2026国产协作机器人排名与选型指南:越疆、遨博、节卡、珞石解析
大数据·数据库·人工智能
Allen_LVyingbo7 小时前
医疗人工智能项目全生命周期管理系统:监管知识建模、工程实现与实证评估(上)
网络·人工智能·机器学习·语言模型·自动化
YangYang9YangYan7 小时前
2026 校招审计风控岗位 JD 拆解,工具、专业能力与面试考点
java·大数据·人工智能·数据分析
明志数科12 小时前
具身智能数据工程观察:“数据筑基“时代的数据底座建设路径
人工智能·机器人
m0_6145235513 小时前
普通视频怎么做多场景一镜到底:路线设计、逐段衔接与整体验收
人工智能·音视频
海宇服务13 小时前
零信任架构实战:基于海宇对外投资历史查询服务构建自动化供应商准入网关
运维·人工智能·架构·自动化
东风破_13 小时前
别急着上 Agentic RAG:先用 LangGraph 把最小 RAG 跑明白
人工智能
LaughingZhu14 小时前
Product Hunt 每日热榜 | 2026-09-12
人工智能·深度学习·神经网络·搜索引擎·百度
天真小巫14 小时前
2026.9.13总结(工作量日益繁重的当下,AI如何提效)
人工智能